diff --git a/docs/fields/default-fields.mdx b/docs/fields/default-fields.mdx index 804927adeaf..5965015ecb9 100644 --- a/docs/fields/default-fields.mdx +++ b/docs/fields/default-fields.mdx @@ -120,7 +120,7 @@ The following fields are added to [upload-enabled collections](../upload/overvie | ---------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `filename`, `mimeType`, `filesize`, `width`, `height`, `url`, `thumbnailURL` | Uploads are enabled. | | `focalX`, `focalY` | Uploads are enabled, unless `focalPoint` is `false` and no Sharp `variants` or image adjustment options (`resizeOptions`, `formatOptions`, and so on) are configured. | -| `sizes` | Sharp `variants` are configured. Matching fields nested under `sizes` are also merged recursively. | +| `variants` | Sharp `variants` are configured. Matching fields nested under `variants` are also merged recursively. | The `file` field is reserved and cannot be overridden. diff --git a/docs/migration-guide/v4.mdx b/docs/migration-guide/v4.mdx index 0850e8d55d3..425aaca5e5c 100644 --- a/docs/migration-guide/v4.mdx +++ b/docs/migration-guide/v4.mdx @@ -1863,7 +1863,7 @@ pnpm add @payloadcms/transformer-sharp Run `npx @payloadcms/codemod --transform migrate-sharp-to-transformer` to migrate automatically. The codemod moves a top-level `sharp` dependency and inline collection Sharp options — including `crop` and `focalPoint` — into `sharpTransformer({ collections })` (writing `imageSizes` as `variants`), appends to an existing `upload.transformers` array if you already have one, and leaves anything it can't safely rewrite in place with a note — for example, a `collections` array built by a function call, or a collection defined outside the `buildConfig` call. Review those manually. For a config that already registers `sharpTransformer`, it only renames `imageSizes` to `variants` inside the existing `sharpTransformer` call and flags a leftover top-level `sharp` property; it doesn't move any remaining collection options. -Your `variants`, `crop`, and `focalPoint` are readable at `collection.upload.variants`/`.crop`/`.focalPoint` on the **sanitized** config (for example `req.payload.collections.media.config.upload.variants`). Code that read the sanitized `upload.imageSizes` in 3.x must read `upload.variants` instead. `sharpTransformer` writes a projection of what you configure back onto the sanitized collection at startup, so the Admin Panel, generated types, and the stored `sizes` shape on your documents are unchanged. For a collection configured on `sharpTransformer`, a `crop` or `focalPoint` set on the transformer replaces the collection's own value; one left unset there keeps the collection's. Neither `imageSizes` nor `variants` can be set on the collection at all. +Your `variants`, `crop`, and `focalPoint` are readable at `collection.upload.variants`/`.crop`/`.focalPoint` on the **sanitized** config (for example `req.payload.collections.media.config.upload.variants`). Code that read the sanitized `upload.imageSizes` in 3.x must read `upload.variants` instead. `sharpTransformer` writes a projection of what you configure back onto the sanitized collection at startup, so the Admin Panel and the rest of core see the same configuration. For a collection configured on `sharpTransformer`, a `crop` or `focalPoint` set on the transformer replaces the collection's own value; one left unset there keeps the collection's. Neither `imageSizes` nor `variants` can be set on the collection at all. If you inject a custom Sharp build or version, pass it the same way: @@ -1874,6 +1874,56 @@ If you inject a custom Sharp build or version, pass it the same way: `sharpTransformer` can also resize images at request time (`/api/media/file/photo.png?width=400`). This is new in Payload 4 and **disabled by default**, so a migrated config keeps serving only the original file and your pre-generated `variants`. The codemod doesn't enable it. To opt in, pass `dynamic: true` or `dynamic: { collections: ['media'] }` — see [Dynamic (request-time) resizing](../upload/transformers#dynamic-request-time-resizing), and [Securing dynamic resizing](../upload/transformers#securing-dynamic-resizing) before enabling it on a publicly readable collection. +#### The stored `sizes` field on upload documents is renamed to `variants` + +The group field that holds each generated image size on an upload document is now `variants` instead of `sizes`. Every place that reads or queries it changes: + +```diff +- doc.sizes?.thumbnail?.url ++ doc.variants?.thumbnail?.url + +- where: { 'sizes.thumbnail.filename': { equals: 'photo-400x300.png' } } ++ where: { 'variants.thumbnail.filename': { equals: 'photo-400x300.png' } } + +- select: { sizes: { thumbnail: true } } ++ select: { variants: { thumbnail: true } } +``` + +GraphQL queries select `variants { thumbnail { url } }` instead of `sizes { thumbnail { url } }`, and regenerated types expose `variants?: { ... }` (and `variants?: T | { ... }` on the `*Select` types) in place of `sizes`. Update any frontend code that reads `media.sizes`, including values you pass to helpers such as `getBestFitFromSizes({ sizes: doc.variants })`. + +Existing documents must be migrated. Create the predefined migration for your database adapter and run it: + + + Payload 4 uses `variants` as the top-level field for generated upload + variants. If an upload collection already defines a custom field named + `variants`, rename that field before you run the predefined migration. Choose + a new name, update your config, and write a custom migration that moves the + existing custom data from `variants` to the new field. Then run the predefined + `sizes-to-variants` migration. If legacy `sizes` data or columns and custom + `variants` data or columns are both present, the predefined migration stops + before it changes data or indexes. It cannot choose a new name or merge this + custom data for you. + + +```sh +# MongoDB +payload migrate:create --file @payloadcms/db-mongodb/sizes-to-variants +# Postgres +payload migrate:create --file @payloadcms/db-postgres/sizes-to-variants +# Vercel Postgres +payload migrate:create --file @payloadcms/db-vercel-postgres/sizes-to-variants +# SQLite +payload migrate:create --file @payloadcms/db-sqlite/sizes-to-variants +# Cloudflare D1 +payload migrate:create --file @payloadcms/db-d1-sqlite/sizes-to-variants + +payload migrate +``` + +**Postgres and SQLite (including Vercel Postgres and D1):** the columns are renamed in place from `sizes__` to `variants__`, on each upload collection's table and its versions table, along with their indexes. Use the predefined migration above **instead of** a plain `payload migrate:create` for this change. A generated migration would drop the `sizes_*` columns and add new `variants_*` columns, losing the stored image size data. The predefined migration also writes the schema snapshot, so the next generated migration starts from the renamed columns. + +On MongoDB the migration renames `sizes` (and `version.sizes` on versions) with `$rename` and re-creates the indexes on those fields under the new path. Run the migration before the upgraded app writes to upload documents. + #### The file endpoint changes once any transformer is registered Registering any transformer under `upload.transformers` — including `sharpTransformer()` with dynamic resizing off — routes every file request through the transformer-aware handler: diff --git a/docs/upload/overview.mdx b/docs/upload/overview.mdx index 7cc07e70101..828e7712dd0 100644 --- a/docs/upload/overview.mdx +++ b/docs/upload/overview.mdx @@ -27,7 +27,7 @@ keywords: uploads, images, media, overview, documentation, Content Management Sy **By simply enabling Upload functionality on a Collection, Payload will automatically transform your Collection into a robust file management / storage solution. The following modifications will be made:** -1. `filename`, `mimeType`, and `filesize` fields will be automatically added to your Collection. Optionally, if you declare image sizes as `variants` on [`sharpTransformer`](#image-sizes), a [`sizes`](#image-sizes) group will also be added containing auto-resized image sizes and filenames. +1. `filename`, `mimeType`, and `filesize` fields will be automatically added to your Collection. Optionally, if you declare image sizes as `variants` on [`sharpTransformer`](#image-sizes), a [`variants`](#image-sizes) group will also be added containing auto-resized image sizes and filenames. 1. The Admin Panel will modify its built-in `List` component to show a thumbnail for each upload within the List View 1. The Admin Panel will modify its `Edit` view(s) to add a new set of corresponding Upload UI which will allow for file upload 1. The `create`, `update`, and `delete` Collection operations will be modified to support file upload, re-upload, and deletion @@ -227,7 +227,7 @@ export default buildConfig({ }) ``` -`sharpTransformer` writes a Sharp-agnostic projection of each configured `variants` entry (`name`, `admin`, and `generateImageName` only) back onto the collection's sanitized `upload.variants`, so the rest of this section — and the generated `sizes` shape on your documents — is unaffected by where the configuration is authored. Neither `imageSizes` nor `variants` can be set on the collection itself. +`sharpTransformer` writes a Sharp-agnostic projection of each configured `variants` entry (`name`, `admin`, and `generateImageName` only) back onto the collection's sanitized `upload.variants`, so the rest of this section — and the generated `variants` shape on your documents — is unaffected by where the configuration is authored. Neither `imageSizes` nor `variants` can be set on the collection itself. #### Admin List View Options diff --git a/docs/upload/transformers.mdx b/docs/upload/transformers.mdx index 47b11dc5d2d..19a783bfd10 100644 --- a/docs/upload/transformers.mdx +++ b/docs/upload/transformers.mdx @@ -280,7 +280,7 @@ export default buildConfig({ }) ``` -At startup, `sharpTransformer` writes a Sharp-agnostic projection of `variants` (name, `admin`, and `generateImageName` only), `crop`, and `focalPoint` back onto the collection's sanitized `upload` config, as `upload.variants`/`.crop`/`.focalPoint`. A `crop` or `focalPoint` set on `sharpTransformer` replaces the collection's own value; when it isn't set there, the collection's value is kept. So the Admin Panel, generated types, and the rest of core see the same sizes as before. The `sizes` shape on your documents is unchanged. `variants` exists only on the sanitized config: setting it (or the removed `imageSizes`) on the collection itself fails the build. +At startup, `sharpTransformer` writes a Sharp-agnostic projection of `variants` (name, `admin`, and `generateImageName` only), `crop`, and `focalPoint` back onto the collection's sanitized `upload` config, as `upload.variants`/`.crop`/`.focalPoint`. A `crop` or `focalPoint` set on `sharpTransformer` replaces the collection's own value; when it isn't set there, the collection's value is kept. So the Admin Panel, generated types, and the rest of core see the same sizes as before. Each generated size is stored on your documents under `variants.`. `variants` exists only on the sanitized config: setting it (or the removed `imageSizes`) on the collection itself fails the build. See [Image Sizes](./overview#image-sizes) for what each Sharp-specific image size option does, and [Crop and Focal Point Selector](./overview#crop-and-focal-point-selector) for the Admin Panel behavior these settings control. diff --git a/packages/db-d1-sqlite/src/exports/migration-utils.ts b/packages/db-d1-sqlite/src/exports/migration-utils.ts index 56f26820e39..f688337fd4f 100644 --- a/packages/db-d1-sqlite/src/exports/migration-utils.ts +++ b/packages/db-d1-sqlite/src/exports/migration-utils.ts @@ -1 +1,2 @@ export { getBlocksToJsonMigrator } from '@payloadcms/drizzle' +export { migrateSqliteSizesToVariants as migrateSizesToVariants } from '@payloadcms/drizzle/sqlite' diff --git a/packages/db-d1-sqlite/src/predefinedMigrations/sizes-to-variants.ts b/packages/db-d1-sqlite/src/predefinedMigrations/sizes-to-variants.ts new file mode 100644 index 00000000000..e89a6fbca64 --- /dev/null +++ b/packages/db-d1-sqlite/src/predefinedMigrations/sizes-to-variants.ts @@ -0,0 +1,7 @@ +import type { DynamicMigrationTemplate } from 'payload' + +import { buildDynamicPredefinedSizesToVariantsMigration } from '@payloadcms/drizzle' + +export const dynamic: DynamicMigrationTemplate = buildDynamicPredefinedSizesToVariantsMigration({ + packageName: '@payloadcms/db-d1-sqlite', +}) diff --git a/packages/db-mongodb/src/exports/migration-utils.ts b/packages/db-mongodb/src/exports/migration-utils.ts index adf041813bb..7ad8b480e00 100644 --- a/packages/db-mongodb/src/exports/migration-utils.ts +++ b/packages/db-mongodb/src/exports/migration-utils.ts @@ -3,4 +3,5 @@ export { migrateLocalizeStatus, } from '../predefinedMigrations/migrateLocalizeStatus.js' export { migrateRelationshipsV2_V3 } from '../predefinedMigrations/migrateRelationshipsV2_V3.js' +export { migrateSizesToVariants } from '../predefinedMigrations/migrateSizesToVariants.js' export { migrateVersionsV1_V2 } from '../predefinedMigrations/migrateVersionsV1_V2.js' diff --git a/packages/db-mongodb/src/predefinedMigrations/migrateSizesToVariants.ts b/packages/db-mongodb/src/predefinedMigrations/migrateSizesToVariants.ts new file mode 100644 index 00000000000..018b1bbda87 --- /dev/null +++ b/packages/db-mongodb/src/predefinedMigrations/migrateSizesToVariants.ts @@ -0,0 +1,437 @@ +import type { + Collection, + CreateIndexesOptions, + Document, + IndexDescriptionInfo, + IndexDirection, +} from 'mongodb' +import type { Payload, PayloadRequest } from 'payload' + +import { isDeepStrictEqual } from 'node:util' + +import type { MongooseAdapter } from '../index.js' + +import { getSession } from '../utilities/getSession.js' + +/** Mongo error codes for an index that conflicts with an existing index. */ +const INDEX_CONFLICT_CODES = new Set([85, 86]) +/** Mongo error code for a collection that hasn't been created yet. */ +const NAMESPACE_NOT_FOUND_CODE = 26 +const GENERATED_VARIANT_METADATA_FIELDS = new Set([ + 'filename', + 'filesize', + 'height', + 'mimeType', + 'url', + 'width', +]) + +/** + * Moves every upload collection's stored image sizes between the legacy `sizes` field and + * `variants`, on each collection and its versions collection (`version.sizes`). Uses `$rename`, + * so no document is rewritten beyond the renamed key, and re-keys the indexes on those fields. + * Documents that are already renamed are left as they are, so it's safe to re-run. + */ +export async function migrateSizesToVariants({ + direction = 'up', + payload, + req, +}: { + direction?: 'down' | 'up' + payload: Payload + req?: Partial +}): Promise { + const adapter = payload.db as unknown as MongooseAdapter + const session = await getSession(adapter, req) + const [from, to] = direction === 'up' ? ['sizes', 'variants'] : ['variants', 'sizes'] + + const targets: { + collection: Collection + expectedVariantNames: string[] + fromPath: string + toPath: string + }[] = [] + + for (const collection of payload.config.collections) { + if (!collection.upload) { + continue + } + + const upload = typeof collection.upload === 'object' ? collection.upload : undefined + const expectedVariantNames = (upload?.variants ?? []).map(({ name }) => name) + + targets.push({ + collection: adapter.collections[collection.slug]!.collection, + expectedVariantNames, + fromPath: from, + toPath: to, + }) + + if (collection.versions && adapter.versions[collection.slug]) { + targets.push({ + collection: adapter.versions[collection.slug]!.collection, + expectedVariantNames, + fromPath: `version.${from}`, + toPath: `version.${to}`, + }) + } + } + + for (const { collection, expectedVariantNames, fromPath, toPath } of targets) { + const [sourceData, directCollision] = await Promise.all([ + collection.findOne({ [fromPath]: { $exists: true } }, { projection: { _id: 1 } }), + collection.findOne( + { [fromPath]: { $exists: true }, [toPath]: { $exists: true } }, + { projection: { _id: 1 } }, + ), + ]) + + if ( + sourceData && + (directCollision || + (await findUnexpectedDestinationData({ collection, expectedVariantNames, toPath }))) + ) { + throw new Error( + `Cannot run the sizes-to-variants migration because collection "${collection.collectionName}" contains both "${fromPath}" and "${toPath}" data. Move or rename the existing destination data before running this migration.`, + ) + } + } + + // Index changes can't be part of a multi-document transaction, and one made after the + // migration's transaction has written to a collection aborts it. A transaction's snapshot starts + // at its first operation, so re-keying every index before any transactional write is safe. + for (const target of targets) { + await rekeyIndexes(target) + } + + for (const { collection, fromPath, toPath } of targets) { + const result = await collection.updateMany( + { [fromPath]: { $exists: true }, [toPath]: { $exists: false } }, + { $rename: { [fromPath]: toPath } }, + { session }, + ) + + payload.logger.info({ + msg: `sizes-to-variants (${direction}): renamed ${fromPath} on ${result.modifiedCount} document(s) in "${collection.collectionName}"`, + }) + } +} + +async function findUnexpectedDestinationData({ + collection, + expectedVariantNames, + toPath, +}: { + collection: Collection + expectedVariantNames: string[] + toPath: string +}): Promise { + const expectedVariantNameSet = new Set(expectedVariantNames) + const cursor = collection.find( + { [toPath]: { $exists: true } }, + { projection: { _id: 1, [toPath]: 1 } }, + ) + + try { + for await (const document of cursor) { + const destinationData = getDocumentPath({ document, path: toPath }) + + if (hasUnexpectedVariantData({ destinationData, expectedVariantNameSet })) { + return document + } + } + } finally { + await cursor.close() + } + + return null +} + +function getDocumentPath({ document, path }: { document: Document; path: string }): unknown { + return path.split('.').reduce((value, pathSegment) => { + if (!isRecord(value)) { + return undefined + } + + return value[pathSegment] + }, document) +} + +function hasUnexpectedVariantData({ + destinationData, + expectedVariantNameSet, +}: { + destinationData: unknown + expectedVariantNameSet: Set +}): boolean { + if (!isRecord(destinationData)) { + return destinationData !== null && destinationData !== undefined + } + + for (const [variantName, variantData] of Object.entries(destinationData)) { + if (!expectedVariantNameSet.has(variantName)) { + return true + } + + if (variantData === null || variantData === undefined) { + continue + } + + if ( + !isRecord(variantData) || + Object.keys(variantData).some( + (metadataFieldName) => !GENERATED_VARIANT_METADATA_FIELDS.has(metadataFieldName), + ) + ) { + return true + } + } + + return false +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +async function rekeyIndexes({ + collection, + fromPath, + toPath, +}: { + collection: Collection + fromPath: string + toPath: string +}): Promise { + let indexes: IndexDescriptionInfo[] + + try { + indexes = await collection.indexes() + } catch (err) { + if ((err as { code?: number }).code === NAMESPACE_NOT_FOUND_CODE) { + return + } + + throw err + } + + for (const index of indexes) { + const sourceKeys = getIndexKeysForCreate({ fromPath, index, toPath: fromPath }) + + if (!Object.keys(sourceKeys).some((key) => isPathAtOrBelow({ key, path: fromPath }))) { + continue + } + + const rekeyedKeys = getIndexKeysForCreate({ fromPath, index, toPath }) + const destinationOptions = getDestinationIndexOptions({ fromPath, index, toPath }) + + if (index.weights && '_fts' in index.key && '_ftsx' in index.key) { + await rekeyTextIndex({ + collection, + destinationOptions, + index, + rekeyedKeys, + sourceKeys, + }) + continue + } + + try { + await collection.createIndex(rekeyedKeys, destinationOptions) + } catch (err) { + if (!INDEX_CONFLICT_CODES.has((err as { code?: number }).code ?? 0)) { + throw err + } + + const destinationIndex = (await collection.indexes()).find((candidate) => + isDeepStrictEqual( + getIndexKeysForCreate({ fromPath: toPath, index: candidate, toPath }), + rekeyedKeys, + ), + ) + const existingDestinationOptions = destinationIndex + ? getDestinationIndexOptions({ + fromPath: toPath, + index: destinationIndex, + toPath, + }) + : undefined + + if (!destinationIndex || !isDeepStrictEqual(existingDestinationOptions, destinationOptions)) { + throw err + } + } + + await collection.dropIndex(index.name!) + } +} + +async function rekeyTextIndex({ + collection, + destinationOptions, + index, + rekeyedKeys, + sourceKeys, +}: { + collection: Collection + destinationOptions: CreateIndexesOptions + index: IndexDescriptionInfo + rekeyedKeys: Record + sourceKeys: Record +}): Promise { + if (!index.name) { + throw new Error('Cannot re-key an unnamed MongoDB text index.') + } + + const sourceOptions: CreateIndexesOptions = { + ...getDestinationIndexOptions({ fromPath: '', index, toPath: '' }), + name: index.name, + } + + await collection.dropIndex(index.name) + + try { + await collection.createIndex(rekeyedKeys, destinationOptions) + } catch (err) { + try { + await collection.createIndex(sourceKeys, sourceOptions) + } catch (restoreErr) { + throw new AggregateError( + [err, restoreErr], + `Failed to create the replacement for MongoDB text index "${index.name}" and failed to restore the source index.`, + ) + } + + throw err + } +} + +function getIndexKeysForCreate({ + fromPath, + index, + toPath, +}: { + fromPath: string + index: IndexDescriptionInfo + toPath: string +}): Record { + const keys: [string, IndexDirection][] = [] + const hasInternalTextKeys = '_fts' in index.key && '_ftsx' in index.key + + for (const [key, value] of Object.entries(index.key)) { + if (hasInternalTextKeys && key === '_fts') { + for (const textPath of Object.keys(index.weights ?? {})) { + keys.push([rekeyPath({ fromPath, path: textPath, toPath }), 'text']) + } + continue + } + + if (hasInternalTextKeys && key === '_ftsx') { + continue + } + + keys.push([rekeyPath({ fromPath, path: key, toPath }), value]) + } + + return Object.fromEntries(keys) +} + +function getDestinationIndexOptions({ + fromPath, + index, + toPath, +}: { + fromPath: string + index: IndexDescriptionInfo + toPath: string +}): CreateIndexesOptions { + const options: CreateIndexesOptions = { + '2dsphereIndexVersion': index['2dsphereIndexVersion'], + background: index.background, + bits: index.bits, + bucketSize: index.bucketSize, + collation: index.collation, + default_language: index.default_language, + expireAfterSeconds: index.expireAfterSeconds, + hidden: index.hidden, + language_override: index.language_override, + max: index.max, + min: index.min, + partialFilterExpression: index.partialFilterExpression + ? rekeyDocumentPaths({ document: index.partialFilterExpression, fromPath, toPath }) + : undefined, + sparse: index.sparse, + storageEngine: index.storageEngine, + textIndexVersion: index.textIndexVersion, + unique: index.unique, + weights: index.weights + ? rekeyDocumentPaths({ document: index.weights, fromPath, toPath }) + : undefined, + wildcardProjection: index.wildcardProjection + ? rekeyDocumentPaths({ document: index.wildcardProjection, fromPath, toPath }) + : undefined, + } + + return Object.fromEntries( + Object.entries(options).filter(([, value]) => value !== undefined), + ) as CreateIndexesOptions +} + +function rekeyDocumentPaths({ + document, + fromPath, + toPath, +}: { + document: Document + fromPath: string + toPath: string +}): Document { + return Object.fromEntries( + Object.entries(document).map(([key, value]) => [ + rekeyPath({ fromPath, path: key, toPath }), + rekeyDocumentValue({ fromPath, toPath, value }), + ]), + ) +} + +function isPathAtOrBelow({ key, path }: { key: string; path: string }): boolean { + return key === path || key.startsWith(`${path}.`) +} + +function rekeyPath({ + fromPath, + path, + toPath, +}: { + fromPath: string + path: string + toPath: string +}): string { + return isPathAtOrBelow({ key: path, path: fromPath }) + ? `${toPath}${path.slice(fromPath.length)}` + : path +} + +function rekeyDocumentValue({ + fromPath, + toPath, + value, +}: { + fromPath: string + toPath: string + value: unknown +}): unknown { + if (Array.isArray(value)) { + return value.map((item) => rekeyDocumentValue({ fromPath, toPath, value: item })) + } + + if ( + value !== null && + typeof value === 'object' && + (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null) + ) { + return rekeyDocumentPaths({ document: value as Document, fromPath, toPath }) + } + + return value +} diff --git a/packages/db-mongodb/src/predefinedMigrations/sizes-to-variants.ts b/packages/db-mongodb/src/predefinedMigrations/sizes-to-variants.ts new file mode 100644 index 00000000000..98f5674df89 --- /dev/null +++ b/packages/db-mongodb/src/predefinedMigrations/sizes-to-variants.ts @@ -0,0 +1,7 @@ +const imports = `import { migrateSizesToVariants } from '@payloadcms/db-mongodb/migration-utils'` +const upSQL = ` await migrateSizesToVariants({ payload, req }) +` +const downSQL = ` await migrateSizesToVariants({ direction: 'down', payload, req }) +` + +export { downSQL, imports, upSQL } diff --git a/packages/db-postgres/scripts/renamePredefinedMigrations.ts b/packages/db-postgres/scripts/renamePredefinedMigrations.ts index 625f82cf00c..8df36e4becb 100644 --- a/packages/db-postgres/scripts/renamePredefinedMigrations.ts +++ b/packages/db-postgres/scripts/renamePredefinedMigrations.ts @@ -2,7 +2,9 @@ import fs from 'fs' import path from 'path' /** - * Changes built .js files to .mjs to for ESM imports + * Adds an .mjs copy of each built .js file for ESM imports of predefined migrations. The .js file + * is kept, since `exports/migration-utils` imports helpers from this folder (e.g. + * `migrateLocalizeStatus.js`) by their .js name. */ const rename = () => { fs.readdirSync(path.resolve('./dist/predefinedMigrations')) @@ -11,7 +13,7 @@ const rename = () => { }) .forEach((file) => { const newPath = path.join('./dist/predefinedMigrations', file) - fs.renameSync(newPath, newPath.replace('.js', '.mjs')) + fs.copyFileSync(newPath, newPath.replace('.js', '.mjs')) }) console.log('done') } diff --git a/packages/db-postgres/src/exports/migration-utils.ts b/packages/db-postgres/src/exports/migration-utils.ts index 5cc7c1543f3..5f23c9c3f16 100644 --- a/packages/db-postgres/src/exports/migration-utils.ts +++ b/packages/db-postgres/src/exports/migration-utils.ts @@ -1,3 +1,4 @@ export { migrateLocalizeStatus } from '../predefinedMigrations/migrateLocalizeStatus.js' export { getBlocksToJsonMigrator } from '@payloadcms/drizzle' export { migratePostgresLocalizeStatus, migratePostgresV2toV3 } from '@payloadcms/drizzle/postgres' +export { migratePostgresSizesToVariants as migrateSizesToVariants } from '@payloadcms/drizzle/postgres' diff --git a/packages/db-postgres/src/predefinedMigrations/sizes-to-variants.ts b/packages/db-postgres/src/predefinedMigrations/sizes-to-variants.ts new file mode 100644 index 00000000000..ea77d967159 --- /dev/null +++ b/packages/db-postgres/src/predefinedMigrations/sizes-to-variants.ts @@ -0,0 +1,7 @@ +import type { DynamicMigrationTemplate } from 'payload' + +import { buildDynamicPredefinedSizesToVariantsMigration } from '@payloadcms/drizzle' + +export const dynamic: DynamicMigrationTemplate = buildDynamicPredefinedSizesToVariantsMigration({ + packageName: '@payloadcms/db-postgres', +}) diff --git a/packages/db-sqlite/src/exports/migration-utils.ts b/packages/db-sqlite/src/exports/migration-utils.ts index 70068fb26d8..b2683900a03 100644 --- a/packages/db-sqlite/src/exports/migration-utils.ts +++ b/packages/db-sqlite/src/exports/migration-utils.ts @@ -1,3 +1,4 @@ export { migrateLocalizeStatus } from '../predefinedMigrations/migrateLocalizeStatus.js' export { getBlocksToJsonMigrator } from '@payloadcms/drizzle' export { migrateSqliteLocalizeStatus } from '@payloadcms/drizzle/sqlite' +export { migrateSqliteSizesToVariants as migrateSizesToVariants } from '@payloadcms/drizzle/sqlite' diff --git a/packages/db-sqlite/src/predefinedMigrations/sizes-to-variants.ts b/packages/db-sqlite/src/predefinedMigrations/sizes-to-variants.ts new file mode 100644 index 00000000000..532fa1a4b9c --- /dev/null +++ b/packages/db-sqlite/src/predefinedMigrations/sizes-to-variants.ts @@ -0,0 +1,7 @@ +import type { DynamicMigrationTemplate } from 'payload' + +import { buildDynamicPredefinedSizesToVariantsMigration } from '@payloadcms/drizzle' + +export const dynamic: DynamicMigrationTemplate = buildDynamicPredefinedSizesToVariantsMigration({ + packageName: '@payloadcms/db-sqlite', +}) diff --git a/packages/db-vercel-postgres/scripts/renamePredefinedMigrations.ts b/packages/db-vercel-postgres/scripts/renamePredefinedMigrations.ts index 625f82cf00c..8df36e4becb 100644 --- a/packages/db-vercel-postgres/scripts/renamePredefinedMigrations.ts +++ b/packages/db-vercel-postgres/scripts/renamePredefinedMigrations.ts @@ -2,7 +2,9 @@ import fs from 'fs' import path from 'path' /** - * Changes built .js files to .mjs to for ESM imports + * Adds an .mjs copy of each built .js file for ESM imports of predefined migrations. The .js file + * is kept, since `exports/migration-utils` imports helpers from this folder (e.g. + * `migrateLocalizeStatus.js`) by their .js name. */ const rename = () => { fs.readdirSync(path.resolve('./dist/predefinedMigrations')) @@ -11,7 +13,7 @@ const rename = () => { }) .forEach((file) => { const newPath = path.join('./dist/predefinedMigrations', file) - fs.renameSync(newPath, newPath.replace('.js', '.mjs')) + fs.copyFileSync(newPath, newPath.replace('.js', '.mjs')) }) console.log('done') } diff --git a/packages/db-vercel-postgres/src/exports/migration-utils.ts b/packages/db-vercel-postgres/src/exports/migration-utils.ts index f5b74e7fa2b..c68f2fe5428 100644 --- a/packages/db-vercel-postgres/src/exports/migration-utils.ts +++ b/packages/db-vercel-postgres/src/exports/migration-utils.ts @@ -1 +1,2 @@ export { migratePostgresV2toV3 } from '@payloadcms/drizzle/postgres' +export { migratePostgresSizesToVariants as migrateSizesToVariants } from '@payloadcms/drizzle/postgres' diff --git a/packages/db-vercel-postgres/src/predefinedMigrations/sizes-to-variants.ts b/packages/db-vercel-postgres/src/predefinedMigrations/sizes-to-variants.ts new file mode 100644 index 00000000000..3b3afb50955 --- /dev/null +++ b/packages/db-vercel-postgres/src/predefinedMigrations/sizes-to-variants.ts @@ -0,0 +1,7 @@ +import type { DynamicMigrationTemplate } from 'payload' + +import { buildDynamicPredefinedSizesToVariantsMigration } from '@payloadcms/drizzle' + +export const dynamic: DynamicMigrationTemplate = buildDynamicPredefinedSizesToVariantsMigration({ + packageName: '@payloadcms/db-vercel-postgres', +}) diff --git a/packages/drizzle/src/exports/postgres.ts b/packages/drizzle/src/exports/postgres.ts index e23795b0be2..3867083dc07 100644 --- a/packages/drizzle/src/exports/postgres.ts +++ b/packages/drizzle/src/exports/postgres.ts @@ -10,6 +10,7 @@ export { execute } from '../postgres/execute.js' export { init } from '../postgres/init.js' export { insert } from '../postgres/insert.js' export { migratePostgresLocalizeStatus } from '../postgres/predefinedMigrations/localize-status/index.js' +export { migratePostgresSizesToVariants } from '../postgres/predefinedMigrations/sizes-to-variants/index.js' export { migratePostgresV2toV3 } from '../postgres/predefinedMigrations/v2-v3/index.js' export { assertOperatorHandlerExtensionsInstalled } from '../postgres/query/assertOperatorHandlerExtensionsInstalled.js' export { postgresUnaccent } from '../postgres/query/postgresUnaccent.js' diff --git a/packages/drizzle/src/exports/sqlite.ts b/packages/drizzle/src/exports/sqlite.ts index 2f829e7bd21..4e782256680 100644 --- a/packages/drizzle/src/exports/sqlite.ts +++ b/packages/drizzle/src/exports/sqlite.ts @@ -9,5 +9,6 @@ export { execute } from '../sqlite/execute.js' export { init } from '../sqlite/init.js' export { insert } from '../sqlite/insert.js' export { migrateSqliteLocalizeStatus } from '../sqlite/predefinedMigrations/localize-status/index.js' +export { migrateSqliteSizesToVariants } from '../sqlite/predefinedMigrations/sizes-to-variants/index.js' export { requireDrizzleKit } from '../sqlite/requireDrizzleKit.js' export * from '../sqlite/types.js' diff --git a/packages/drizzle/src/index.ts b/packages/drizzle/src/index.ts index 4be9cff1015..3dec8e6cb63 100644 --- a/packages/drizzle/src/index.ts +++ b/packages/drizzle/src/index.ts @@ -91,6 +91,7 @@ export { getBlocksToJsonMigrator, } from './utilities/blocksToJsonMigrator.js' export { buildCreateMigration } from './utilities/buildCreateMigration.js' +export { buildDynamicPredefinedSizesToVariantsMigration } from './utilities/buildDynamicPredefinedSizesToVariantsMigration.js' export { buildIndexName } from './utilities/buildIndexName.js' export { createSchemaGenerator } from './utilities/createSchemaGenerator.js' export { executeSchemaHooks } from './utilities/executeSchemaHooks.js' diff --git a/packages/drizzle/src/postgres/predefinedMigrations/sizes-to-variants/index.ts b/packages/drizzle/src/postgres/predefinedMigrations/sizes-to-variants/index.ts new file mode 100644 index 00000000000..a01c2f892b1 --- /dev/null +++ b/packages/drizzle/src/postgres/predefinedMigrations/sizes-to-variants/index.ts @@ -0,0 +1,138 @@ +import type { Payload } from 'payload' + +import { sql } from 'drizzle-orm' + +import type { SizesToVariantsDirection } from '../../../utilities/getSizesToVariantsRenames.js' +import type { BasePostgresAdapter } from '../../types.js' + +import { + assertNoSizesToVariantsColumnCollisions, + getSizesToVariantsRenames, +} from '../../../utilities/getSizesToVariantsRenames.js' + +/** + * Moves every upload collection's stored image sizes between the legacy `sizes_*` columns and + * `variants_*` (main and versions tables), renaming each column in place so no data is copied, + * then renames the indexes on those columns to what the current schema expects. Skips columns + * that are already renamed, so it's safe to re-run. + */ +export async function migratePostgresSizesToVariants({ + db, + direction = 'up', + payload, +}: { + db: any + direction?: SizesToVariantsDirection + payload: Payload +}): Promise { + const adapter = payload.db as unknown as BasePostgresAdapter + const schemaName = adapter.schemaName || 'public' + const plan = getSizesToVariantsRenames({ adapter, direction }) + const plannedTables: Array< + (typeof plan)[number] & { + existingColumns: Set + } + > = [] + + for (const tablePlan of plan) { + const existingColumns = await getColumnNames({ + db, + schemaName, + tableName: tablePlan.tableName, + }) + + assertNoSizesToVariantsColumnCollisions({ + columns: tablePlan.columns, + existingColumns, + tableName: tablePlan.tableName, + }) + + plannedTables.push({ ...tablePlan, existingColumns }) + } + + for (const { columns, existingColumns, indexes, tableName } of plannedTables) { + let renamedCount = 0 + + for (const { from, to } of columns) { + if (!existingColumns.has(from) || existingColumns.has(to)) { + continue + } + + await db.execute(sql` + ALTER TABLE ${sql.identifier(schemaName)}.${sql.identifier(tableName)} + RENAME COLUMN ${sql.identifier(from)} TO ${sql.identifier(to)} + `) + renamedCount++ + } + + const existingIndexes = await getIndexes({ db, schemaName, tableName }) + + for (const index of indexes) { + const existingName = + index.from ?? + existingIndexes.find(({ columns }) => columns.join(',') === index.columns.join(','))?.name + + if ( + !existingName || + existingName === index.to || + !existingIndexes.some(({ name }) => name === existingName) || + existingIndexes.some(({ name }) => name === index.to) + ) { + continue + } + + await db.execute(sql` + ALTER INDEX ${sql.identifier(schemaName)}.${sql.identifier(existingName)} + RENAME TO ${sql.identifier(index.to)} + `) + } + + payload.logger.info({ + msg: `sizes-to-variants (${direction}): renamed ${renamedCount} column(s) on "${tableName}"`, + }) + } +} + +async function getColumnNames({ + db, + schemaName, + tableName, +}: { + db: any + schemaName: string + tableName: string +}): Promise> { + const result = await db.execute(sql` + SELECT column_name FROM information_schema.columns + WHERE table_schema = ${schemaName} AND table_name = ${tableName} + `) + + return new Set(result.rows.map((row: { column_name: string }) => row.column_name)) +} + +async function getIndexes({ + db, + schemaName, + tableName, +}: { + db: any + schemaName: string + tableName: string +}): Promise<{ columns: string[]; name: string }[]> { + const result = await db.execute(sql` + SELECT i.relname AS name, array_agg(a.attname::text ORDER BY k.ord) AS columns + FROM pg_index ix + JOIN pg_class t ON t.oid = ix.indrelid + JOIN pg_namespace n ON n.oid = t.relnamespace + JOIN pg_class i ON i.oid = ix.indexrelid + CROSS JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY AS k(attnum, ord) + JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = k.attnum + WHERE n.nspname = ${schemaName} AND t.relname = ${tableName} + GROUP BY i.relname + `) + + return result.rows.map((row: { columns: string[]; name: string }) => ({ + name: row.name, + columns: row.columns, + })) +} diff --git a/packages/drizzle/src/sqlite/predefinedMigrations/sizes-to-variants/index.ts b/packages/drizzle/src/sqlite/predefinedMigrations/sizes-to-variants/index.ts new file mode 100644 index 00000000000..9f6e3b65293 --- /dev/null +++ b/packages/drizzle/src/sqlite/predefinedMigrations/sizes-to-variants/index.ts @@ -0,0 +1,170 @@ +import type { Payload } from 'payload' + +import { sql } from 'drizzle-orm' + +import type { DrizzleAdapter } from '../../../types.js' +import type { SizesToVariantsDirection } from '../../../utilities/getSizesToVariantsRenames.js' + +import { + assertNoSizesToVariantsColumnCollisions, + getSizesToVariantsRenames, + isGeneratedLegacyIndexName, +} from '../../../utilities/getSizesToVariantsRenames.js' + +/** + * Moves every upload collection's stored image sizes between the legacy `sizes_*` columns and + * `variants_*` (main and versions tables), renaming each column in place so no data is copied. + * SQLite can't rename an index, so each index on a renamed column is dropped and recreated under + * the name the current schema expects. Skips columns that are already renamed, so it's safe to + * re-run. + */ +export async function migrateSqliteSizesToVariants({ + db, + direction = 'up', + payload, +}: { + db: any + direction?: SizesToVariantsDirection + payload: Payload +}): Promise { + const adapter = payload.db as unknown as DrizzleAdapter + const plan = getSizesToVariantsRenames({ adapter, direction }) + const plannedTables: Array< + (typeof plan)[number] & { + existingColumns: Set + } + > = [] + + for (const tablePlan of plan) { + const existingColumns = await getColumnNames({ db, tableName: tablePlan.tableName }) + + assertNoSizesToVariantsColumnCollisions({ + columns: tablePlan.columns, + existingColumns, + tableName: tablePlan.tableName, + }) + + plannedTables.push({ ...tablePlan, existingColumns }) + } + + for (const { columns, existingColumns, indexes, tableName } of plannedTables) { + const columnsAfterRename = new Set(existingColumns) + let renamedCount = 0 + + for (const { from, to } of columns) { + if (!existingColumns.has(from) || existingColumns.has(to)) { + continue + } + + columnsAfterRename.delete(from) + columnsAfterRename.add(to) + + await db.run(sql` + ALTER TABLE ${sql.identifier(tableName)} + RENAME COLUMN ${sql.identifier(from)} TO ${sql.identifier(to)} + `) + renamedCount++ + } + + const existingIndexes = await getIndexes({ db, tableName }) + + for (const index of indexes) { + if (!index.columns.every((column) => columnsAfterRename.has(column))) { + continue + } + + const destinationIndex = existingIndexes.find(({ name }) => name === index.to) + const sourceIndex = destinationIndex + ? undefined + : index.from + ? existingIndexes.find(({ name }) => name === index.from) + : index.legacyNameBase + ? existingIndexes.find( + ({ name, columns }) => + columns.join(',') === index.columns.join(',') && + isGeneratedLegacyIndexName({ + indexName: name, + legacyNameBase: index.legacyNameBase, + }), + ) + : undefined + + if (destinationIndex) { + if ( + destinationIndex.columns.join(',') !== index.columns.join(',') || + destinationIndex.unique !== index.unique + ) { + throw new Error( + `Cannot run the sizes-to-variants migration because index "${index.to}" on table "${tableName}" does not match the expected columns or uniqueness.`, + ) + } + } else { + const unique = index.unique ? sql.raw('UNIQUE ') : sql.empty() + const columnList = sql.join( + index.columns.map((column) => sql.identifier(column)), + sql.raw(', '), + ) + + await db.run(sql` + CREATE ${unique}INDEX ${sql.identifier(index.to)} + ON ${sql.identifier(tableName)} (${columnList}) + `) + } + + if (sourceIndex && sourceIndex.name !== index.to) { + await db.run(sql`DROP INDEX ${sql.identifier(sourceIndex.name)}`) + } + } + + payload.logger.info({ + msg: `sizes-to-variants (${direction}): renamed ${renamedCount} column(s) on "${tableName}"`, + }) + } +} + +async function getColumnNames({ + db, + tableName, +}: { + db: any + tableName: string +}): Promise> { + const rows: { name: string }[] = await db.all( + sql`SELECT name FROM pragma_table_info(${tableName})`, + ) + + return new Set(rows.map(({ name }) => name)) +} + +async function getIndexes({ + db, + tableName, +}: { + db: any + tableName: string +}): Promise<{ columns: string[]; name: string; unique: boolean }[]> { + // `origin = 'c'` keeps only `CREATE INDEX` indexes, skipping SQLite's internal autoindexes. + const rows: { column_name: string; index_name: string; is_unique: number; seqno: number }[] = + await db.all(sql` + SELECT + il.name AS index_name, + il."unique" AS is_unique, + ii.name AS column_name, + ii.seqno AS seqno + FROM pragma_index_list(${tableName}) AS il + JOIN pragma_index_info(il.name) AS ii + WHERE il.origin = 'c' + ORDER BY il.name, ii.seqno + `) + + const indexes = new Map() + + for (const { column_name, index_name, is_unique } of rows) { + const index = indexes.get(index_name) ?? { columns: [], unique: Boolean(is_unique) } + + index.columns.push(column_name) + indexes.set(index_name, index) + } + + return [...indexes].map(([name, index]) => ({ name, ...index })) +} diff --git a/packages/drizzle/src/utilities/buildDynamicPredefinedSizesToVariantsMigration.spec.ts b/packages/drizzle/src/utilities/buildDynamicPredefinedSizesToVariantsMigration.spec.ts new file mode 100644 index 00000000000..768d8b0eac1 --- /dev/null +++ b/packages/drizzle/src/utilities/buildDynamicPredefinedSizesToVariantsMigration.spec.ts @@ -0,0 +1,240 @@ +import fs from 'fs' +import os from 'os' +import path from 'path' +import { afterEach, describe, expect, it } from 'vitest' + +import { buildDynamicPredefinedSizesToVariantsMigration } from './buildDynamicPredefinedSizesToVariantsMigration.js' +import { getSizesToVariantsRenames } from './getSizesToVariantsRenames.js' + +const tempDirs: string[] = [] + +const makeMigrationDir = () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sizes-to-variants-')) + tempDirs.push(dir) + return dir +} + +/** A 3.x snapshot: a camelCase `heroLarge` size stored as `sizes_*`, plus an unrelated table. */ +const legacySnapshot = { + id: 'previous-snapshot-id', + tables: { + 'public.media': { + name: 'media', + columns: { + id: { name: 'id' }, + sizes_hero_large_filename: { name: 'sizes_hero_large_filename' }, + }, + indexes: { + media_sizes_hero_large_sizes_hero_large_filename_idx: { + name: 'media_sizes_hero_large_sizes_hero_large_filename_idx', + columns: [{ expression: 'sizes_hero_large_filename', isExpression: false }], + }, + }, + }, + 'public.posts': { + name: 'posts', + columns: { title: { name: 'title' } }, + indexes: {}, + }, + }, + version: '7', +} + +const makePayload = ({ migrationDir }: { migrationDir: string }) => + ({ + db: { + migrationDir, + payload: { + config: { + collections: [ + { + slug: 'media', + upload: { variants: [{ name: 'heroLarge' }] }, + }, + ], + }, + }, + rawTables: { + media: { + name: 'media', + columns: { + id: { name: 'id' }, + variants_heroLarge_filename: { name: 'variants_hero_large_filename' }, + }, + indexes: { + media_variants_hero_large_variants_hero_large_filename_idx: { + name: 'media_variants_hero_large_variants_hero_large_filename_idx', + on: 'variants_heroLarge_filename', + }, + }, + }, + }, + requireDrizzleKit: () => ({ generateDrizzleJson: () => Promise.resolve({ version: '7' }) }), + schema: {}, + tableNameMap: new Map([['media', 'media']]), + versionsSuffix: '_v', + }, + }) as any + +describe('buildDynamicPredefinedSizesToVariantsMigration', () => { + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { force: true, recursive: true }) + } + }) + + it('should write the previous snapshot with only the sizes-to-variants renames applied', async () => { + const migrationDir = makeMigrationDir() + const filePath = path.join(migrationDir, '20260102_000000_sizes_to_variants') + + fs.writeFileSync( + path.join(migrationDir, '20260101_000000_initial.json'), + JSON.stringify(legacySnapshot), + ) + + await buildDynamicPredefinedSizesToVariantsMigration({ + packageName: '@payloadcms/db-postgres', + })({ + filePath, + payload: makePayload({ migrationDir }), + }) + + const written = JSON.parse(fs.readFileSync(`${filePath}.json`, 'utf8')) + const media = written.tables['public.media'] + + expect(Object.keys(media.columns)).toEqual(['id', 'variants_hero_large_filename']) + expect(media.indexes).toEqual({ + media_variants_hero_large_variants_hero_large_filename_idx: { + name: 'media_variants_hero_large_variants_hero_large_filename_idx', + columns: [{ expression: 'variants_hero_large_filename', isExpression: false }], + }, + }) + expect(written.tables['public.posts']).toEqual(legacySnapshot.tables['public.posts']) + expect(written.prevId).toBe(legacySnapshot.id) + }) + + it('should not write a snapshot when the project has no previous one', async () => { + const migrationDir = makeMigrationDir() + const filePath = path.join(migrationDir, '20260102_000000_sizes_to_variants') + + await buildDynamicPredefinedSizesToVariantsMigration({ + packageName: '@payloadcms/db-postgres', + })({ + filePath, + payload: makePayload({ migrationDir }), + }) + + expect(fs.existsSync(`${filePath}.json`)).toBe(false) + }) + + it('should only plan columns for configured generated variants', () => { + const migrationDir = makeMigrationDir() + const payload = makePayload({ migrationDir }) + const collection = payload.db.payload.config.collections[0] + + collection.fields = [ + { + fields: [{ name: 'customValue', type: 'text' }], + name: 'variants', + type: 'group', + }, + ] + payload.db.rawTables.media.columns.variants_heroLarge_credit = { + name: 'variants_hero_large_credit', + } + + expect(getSizesToVariantsRenames({ adapter: payload.db, direction: 'up' })[0]?.columns).toEqual( + [ + { + from: 'sizes_hero_large_filename', + to: 'variants_hero_large_filename', + }, + ], + ) + }) + + it('should reject a snapshot that contains both source and destination columns', async () => { + const migrationDir = makeMigrationDir() + const filePath = path.join(migrationDir, '20260102_000000_sizes_to_variants') + const snapshotWithCollision = structuredClone(legacySnapshot) + + snapshotWithCollision.tables['public.media'].columns.variants_hero_large_filename = { + name: 'variants_hero_large_filename', + } + + fs.writeFileSync( + path.join(migrationDir, '20260101_000000_initial.json'), + JSON.stringify(snapshotWithCollision), + ) + + await expect( + buildDynamicPredefinedSizesToVariantsMigration({ + packageName: '@payloadcms/db-postgres', + })({ + filePath, + payload: makePayload({ migrationDir }), + }), + ).rejects.toThrow( + 'contains both "sizes_hero_large_filename" and "variants_hero_large_filename"', + ) + + expect(fs.existsSync(`${filePath}.json`)).toBe(false) + }) + + it('should reject a snapshot that contains legacy sizes and a disjoint custom variants field', async () => { + const migrationDir = makeMigrationDir() + const filePath = path.join(migrationDir, '20260102_000000_sizes_to_variants') + const snapshotWithCustomVariants = structuredClone(legacySnapshot) + + snapshotWithCustomVariants.tables['public.media'].columns.variants_custom_value = { + name: 'variants_custom_value', + } + + fs.writeFileSync( + path.join(migrationDir, '20260101_000000_initial.json'), + JSON.stringify(snapshotWithCustomVariants), + ) + + await expect( + buildDynamicPredefinedSizesToVariantsMigration({ + packageName: '@payloadcms/db-postgres', + })({ + filePath, + payload: makePayload({ migrationDir }), + }), + ).rejects.toThrow('contains both the "sizes" and "variants" fields') + + expect(fs.existsSync(`${filePath}.json`)).toBe(false) + }) + + it('should reject a snapshot that contains both source and destination indexes', async () => { + const migrationDir = makeMigrationDir() + const filePath = path.join(migrationDir, '20260102_000000_sizes_to_variants') + const snapshotWithCollision = structuredClone(legacySnapshot) + + snapshotWithCollision.tables[ + 'public.media' + ].indexes.media_variants_hero_large_variants_hero_large_filename_idx = { + name: 'media_variants_hero_large_variants_hero_large_filename_idx', + columns: [{ expression: 'variants_hero_large_filename', isExpression: false }], + } + + fs.writeFileSync( + path.join(migrationDir, '20260101_000000_initial.json'), + JSON.stringify(snapshotWithCollision), + ) + + await expect( + buildDynamicPredefinedSizesToVariantsMigration({ + packageName: '@payloadcms/db-postgres', + })({ + filePath, + payload: makePayload({ migrationDir }), + }), + ).rejects.toThrow( + 'contains both indexes "media_sizes_hero_large_sizes_hero_large_filename_idx" and "media_variants_hero_large_variants_hero_large_filename_idx"', + ) + + expect(fs.existsSync(`${filePath}.json`)).toBe(false) + }) +}) diff --git a/packages/drizzle/src/utilities/buildDynamicPredefinedSizesToVariantsMigration.ts b/packages/drizzle/src/utilities/buildDynamicPredefinedSizesToVariantsMigration.ts new file mode 100644 index 00000000000..e57b22d119a --- /dev/null +++ b/packages/drizzle/src/utilities/buildDynamicPredefinedSizesToVariantsMigration.ts @@ -0,0 +1,173 @@ +import type { DynamicMigrationTemplate } from 'payload' + +import { randomUUID } from 'crypto' +import { readdirSync, readFileSync, writeFileSync } from 'fs' + +import type { DrizzleAdapter } from '../types.js' +import type { SizesToVariantsTableRenames } from './getSizesToVariantsRenames.js' + +import { + findSizesToVariantsFieldCollision, + getSizesToVariantsRenames, +} from './getSizesToVariantsRenames.js' + +type SnapshotIndex = { + columns: ({ expression: string } | string)[] + name: string +} + +type SnapshotTable = { + columns: Record + indexes?: Record + name: string +} + +type Snapshot = { + id?: string + prevId?: string + tables: Record + version: string +} + +/** + * Builds the `sizes-to-variants` predefined migration for a SQL adapter. The migration renames + * columns rather than letting drizzle-kit diff the schema (which would drop and re-add them, + * losing every stored image size). + * + * It also writes a schema snapshot: the project's previous snapshot with only these renames + * applied. The next generated migration then diffs from the renamed columns instead of dropping + * them, and still picks up every other schema change the upgrade brings. Without a previous + * snapshot (a project that has only ever used `push`) there's nothing to update, so none is written. + */ +export const buildDynamicPredefinedSizesToVariantsMigration = ({ + packageName, +}: { + packageName: string +}): DynamicMigrationTemplate => { + return async ({ filePath, payload }) => { + const adapter = payload.db as unknown as DrizzleAdapter + const previousSnapshot = await readLatestSnapshot({ adapter }) + + if (previousSnapshot) { + const renamedSnapshot = applySizesToVariantsRenames({ + plan: getSizesToVariantsRenames({ adapter, direction: 'up' }), + snapshot: previousSnapshot, + }) + + writeFileSync(`${filePath}.json`, JSON.stringify(renamedSnapshot, null, 2)) + } + + return { + downSQL: ` await migrateSizesToVariants({ db, direction: 'down', payload })`, + imports: `import { migrateSizesToVariants } from '${packageName}/migration-utils'`, + upSQL: ` await migrateSizesToVariants({ db, payload })`, + } + } +} + +/** The latest snapshot in the migrations directory, upgraded to drizzle-kit's current format. */ +async function readLatestSnapshot({ + adapter, +}: { + adapter: DrizzleAdapter +}): Promise { + const dir = adapter.migrationDir + const latestSnapshotFile = readdirSync(dir) + .filter((file) => file.endsWith('.json')) + .sort() + .reverse()[0] + + if (!latestSnapshotFile) { + return undefined + } + + const { generateDrizzleJson, upSnapshot } = adapter.requireDrizzleKit() + const currentVersion = (await generateDrizzleJson(adapter.schema)).version + const snapshot = JSON.parse(readFileSync(`${dir}/${latestSnapshotFile}`, 'utf8')) as Snapshot + + return upSnapshot && snapshot.version < currentVersion ? upSnapshot(snapshot) : snapshot +} + +function applySizesToVariantsRenames({ + plan, + snapshot, +}: { + plan: SizesToVariantsTableRenames[] + snapshot: Snapshot +}): Snapshot { + const renamed: Snapshot = structuredClone(snapshot) + + for (const { columns, indexes, tableName } of plan) { + const table = Object.values(renamed.tables).find(({ name }) => name === tableName) + + if (!table) { + continue + } + + const existingColumns = new Set(Object.values(table.columns).map(({ name }) => name)) + + for (const { from, to } of columns) { + if (existingColumns.has(from) && existingColumns.has(to)) { + throw new Error( + `Cannot create the sizes-to-variants migration because snapshot table "${tableName}" contains both "${from}" and "${to}". Move or rename the existing destination data before creating this migration.`, + ) + } + } + + const fieldCollision = findSizesToVariantsFieldCollision({ columns, existingColumns }) + + if (fieldCollision) { + throw new Error( + `Cannot create the sizes-to-variants migration because snapshot table "${tableName}" contains both the "${fieldCollision.from}" and "${fieldCollision.to}" fields. Move or rename the existing destination data before creating this migration.`, + ) + } + + for (const { from, to } of columns) { + const column = table.columns[from] + + if (column) { + if (table.columns[to]) { + throw new Error( + `Cannot create the sizes-to-variants migration because snapshot table "${tableName}" contains both "${from}" and "${to}". Move or rename the existing destination data before creating this migration.`, + ) + } + + delete table.columns[from] + table.columns[to] = { ...column, name: to } + } + } + + for (const [key, index] of Object.entries(table.indexes ?? {})) { + const indexColumns = index.columns.map((column) => { + const name = typeof column === 'string' ? column : column.expression + const renamedName = columns.find(({ from }) => from === name)?.to ?? name + + return typeof column === 'string' ? renamedName : { ...column, expression: renamedName } + }) + const indexColumnNames = indexColumns.map((column) => + typeof column === 'string' ? column : column.expression, + ) + const target = indexes.find( + (planned) => planned.columns.join(',') === indexColumnNames.join(','), + ) + + if (!target) { + continue + } + + if (key !== target.to && table.indexes?.[target.to]) { + throw new Error( + `Cannot create the sizes-to-variants migration because snapshot table "${tableName}" contains both indexes "${key}" and "${target.to}". Move or rename the existing destination index before creating this migration.`, + ) + } + + delete table.indexes[key] + table.indexes[target.to] = { ...index, name: target.to, columns: indexColumns } + } + } + + renamed.prevId = snapshot.id + renamed.id = randomUUID() + + return renamed +} diff --git a/packages/drizzle/src/utilities/getSizesToVariantsRenames.ts b/packages/drizzle/src/utilities/getSizesToVariantsRenames.ts new file mode 100644 index 00000000000..784ebc0a0e0 --- /dev/null +++ b/packages/drizzle/src/utilities/getSizesToVariantsRenames.ts @@ -0,0 +1,287 @@ +import toSnakeCase from 'to-snake-case' + +import type { DrizzleAdapter, RawIndex, RawTable } from '../types.js' + +import { maxGeneratedIdentifierLength } from './validateIdentifierLength.js' + +export type SizesToVariantsDirection = 'down' | 'up' + +export type SizesToVariantsColumnRename = { + from: string + to: string +} + +export type SizesToVariantsIndexRename = { + /** Columns the index covers, named as they are after the column renames. */ + columns: string[] + /** The index name to rename from on `up` (unknown up front — looked up by column at runtime). */ + from?: string + /** Base used to verify a generated legacy index name, including a possible numeric suffix. */ + legacyNameBase?: string + to: string + unique: boolean +} + +export type SizesToVariantsTableRenames = { + columns: SizesToVariantsColumnRename[] + indexes: SizesToVariantsIndexRename[] + tableName: string +} + +export function findSizesToVariantsFieldCollision({ + columns, + existingColumns, +}: { + columns: SizesToVariantsColumnRename[] + existingColumns: Set +}): { from: string; to: string } | undefined { + const firstRename = columns[0] + + if (!firstRename) { + return undefined + } + + const fromField = getSizesToVariantsField({ columnName: firstRename.from }) + const toField = getSizesToVariantsField({ columnName: firstRename.to }) + + if (!fromField || !toField) { + return undefined + } + + const hasSourceField = [...existingColumns].some((column) => + column.startsWith(fromField.columnPrefix), + ) + const plannedDestinationColumns = new Set(columns.map(({ to }) => to)) + const hasUnexpectedDestinationField = [...existingColumns].some( + (column) => column.startsWith(toField.columnPrefix) && !plannedDestinationColumns.has(column), + ) + + return hasSourceField && hasUnexpectedDestinationField + ? { from: fromField.fieldPath, to: toField.fieldPath } + : undefined +} + +export function assertNoSizesToVariantsColumnCollisions({ + columns, + existingColumns, + tableName, +}: { + columns: SizesToVariantsColumnRename[] + existingColumns: Set + tableName: string +}): void { + for (const { from, to } of columns) { + if (existingColumns.has(from) && existingColumns.has(to)) { + throw new Error( + `Cannot run the sizes-to-variants migration because table "${tableName}" contains both "${from}" and "${to}". Move or rename the existing destination data before running this migration.`, + ) + } + } + + const fieldCollision = findSizesToVariantsFieldCollision({ columns, existingColumns }) + + if (fieldCollision) { + throw new Error( + `Cannot run the sizes-to-variants migration because table "${tableName}" contains both the "${fieldCollision.from}" and "${fieldCollision.to}" fields. Move or rename the existing destination data before running this migration.`, + ) + } +} + +const columnPrefixes = [ + { current: 'variants_', legacy: 'sizes_' }, + { current: 'version_variants_', legacy: 'version_sizes_' }, +] +const generatedVariantMetadataFieldNames = [ + 'filename', + 'filesize', + 'height', + 'mimeType', + 'url', + 'width', +] + +/** + * Plans the column and index renames that move every upload collection's stored image sizes + * between the legacy `sizes` group and `variants`. Reads the current (`variants`) schema the + * adapter built from the config, so it covers each collection's main and versions tables without + * re-deriving Payload's column naming. + * + * Index names come from the schema on `up`, and on `down` are rebuilt the way `buildIndexName` + * named them before the rename. The runner still has to locate the existing index by column on + * `up`, since a legacy name may have been de-duplicated with a numeric suffix. + */ +export function getSizesToVariantsRenames({ + adapter, + direction, +}: { + adapter: DrizzleAdapter + direction: SizesToVariantsDirection +}): SizesToVariantsTableRenames[] { + const plan: SizesToVariantsTableRenames[] = [] + + for (const collection of adapter.payload.config.collections) { + if (!collection.upload) { + continue + } + + const upload = typeof collection.upload === 'object' ? collection.upload : undefined + const generatedVariantColumnKeys = new Set( + (upload?.variants ?? []).flatMap(({ name }) => + generatedVariantMetadataFieldNames.flatMap((metadataFieldName) => [ + `variants_${name}_${metadataFieldName}`, + `version_variants_${name}_${metadataFieldName}`, + ]), + ), + ) + + if (generatedVariantColumnKeys.size === 0) { + continue + } + + const tableNames = [adapter.tableNameMap.get(toSnakeCase(collection.slug))] + + if (collection.versions) { + tableNames.push( + adapter.tableNameMap.get(`_${toSnakeCase(collection.slug)}${adapter.versionsSuffix}`), + ) + } + + for (const tableName of tableNames) { + const rawTable = tableName ? adapter.rawTables[tableName] : undefined + + if (!tableName || !rawTable) { + continue + } + + const columns: SizesToVariantsColumnRename[] = [] + + for (const [columnKey, { name }] of Object.entries(rawTable.columns)) { + if (!generatedVariantColumnKeys.has(columnKey)) { + continue + } + + const prefix = columnPrefixes.find(({ current }) => name.startsWith(current)) + + if (!prefix) { + continue + } + + const legacyName = `${prefix.legacy}${name.slice(prefix.current.length)}` + + columns.push( + direction === 'up' ? { from: legacyName, to: name } : { from: name, to: legacyName }, + ) + } + + if (columns.length === 0) { + continue + } + + const indexes = Object.values(rawTable.indexes ?? {}) + .map((index) => + planIndexRename({ columns, direction, index, rawColumns: rawTable.columns, tableName }), + ) + .filter((index): index is SizesToVariantsIndexRename => Boolean(index)) + + plan.push({ columns, indexes, tableName }) + } + } + + return plan +} + +function getSizesToVariantsField({ + columnName, +}: { + columnName: string +}): { columnPrefix: string; fieldPath: string } | undefined { + if (columnName.startsWith('version_sizes_')) { + return { columnPrefix: 'version_sizes_', fieldPath: 'version.sizes' } + } + + if (columnName.startsWith('version_variants_')) { + return { columnPrefix: 'version_variants_', fieldPath: 'version.variants' } + } + + if (columnName.startsWith('sizes_')) { + return { columnPrefix: 'sizes_', fieldPath: 'sizes' } + } + + if (columnName.startsWith('variants_')) { + return { columnPrefix: 'variants_', fieldPath: 'variants' } + } + + return undefined +} + +function planIndexRename({ + columns, + direction, + index, + rawColumns, + tableName, +}: { + columns: SizesToVariantsColumnRename[] + direction: SizesToVariantsDirection + index: RawIndex + rawColumns: RawTable['columns'] + tableName: string +}): SizesToVariantsIndexRename | undefined { + // `on` holds the column's schema key (e.g. `variants_heroLarge_filename`), not its database name. + const currentColumns = [index.on].flat().map((key) => rawColumns[key]?.name ?? key) + const renamedColumns = currentColumns.map((column) => + columns.find((rename) => (direction === 'up' ? rename.to : rename.from) === column), + ) + + if (renamedColumns.every((rename) => !rename)) { + return undefined + } + + const unique = Boolean(index.unique) + + if (direction === 'up') { + const legacyColumns = currentColumns.map((column, i) => renamedColumns[i]?.from ?? column) + + return { + columns: currentColumns, + legacyNameBase: `${tableName}_${legacyColumns.join('_')}`, + to: index.name, + unique, + } + } + + const legacyColumns = currentColumns.map((column, i) => renamedColumns[i]?.to ?? column) + + return { + columns: legacyColumns, + from: index.name, + to: buildLegacyIndexName(`${tableName}_${legacyColumns.join('_')}`), + unique, + } +} + +/** Mirrors `buildIndexName` for an index that didn't collide, without registering the name. */ +export function isGeneratedLegacyIndexName({ + indexName, + legacyNameBase, +}: { + indexName: string + legacyNameBase: string +}): boolean { + const suffixMatch = indexName.match(/(?:_(\d+))?_idx$/) + + if (!suffixMatch) { + return false + } + + return indexName === buildLegacyIndexName(legacyNameBase, Number(suffixMatch[1] ?? 0)) +} + +function buildLegacyIndexName(name: string, number = 0): string { + const suffix = `${number ? `_${number}` : ''}_idx` + const indexName = `${name}${suffix}` + + return indexName.length > maxGeneratedIdentifierLength + ? `${name.slice(0, maxGeneratedIdentifierLength - suffix.length)}${suffix}` + : indexName +} diff --git a/packages/payload-cloud/src/hooks/afterDelete.ts b/packages/payload-cloud/src/hooks/afterDelete.ts index 968c960f72c..a8a626929b9 100644 --- a/packages/payload-cloud/src/hooks/afterDelete.ts +++ b/packages/payload-cloud/src/hooks/afterDelete.ts @@ -18,7 +18,7 @@ export const getAfterDeleteHook = ({ const filesToDelete: string[] = [ doc.filename || '', - ...Object.values(doc?.sizes || []) + ...Object.values(doc?.variants || []) .map((resizedFileData) => resizedFileData.filename) .filter((filename): filename is string => filename !== null), ] diff --git a/packages/payload-cloud/src/utilities/getIncomingFiles.ts b/packages/payload-cloud/src/utilities/getIncomingFiles.ts index 3b8ea59e945..1bf7b659a86 100644 --- a/packages/payload-cloud/src/utilities/getIncomingFiles.ts +++ b/packages/payload-cloud/src/utilities/getIncomingFiles.ts @@ -24,8 +24,8 @@ export function getIncomingFiles({ files = [mainFile] - if (data?.sizes) { - Object.entries(data.sizes).forEach(([key, resizedFileData]) => { + if (data?.variants) { + Object.entries(data.variants).forEach(([key, resizedFileData]) => { if (req.payloadUploadSizes?.[key] && resizedFileData.mimeType) { files = files.concat([ { diff --git a/packages/payload/src/collections/config/types.ts b/packages/payload/src/collections/config/types.ts index 021987bbde1..4dcbee4e0af 100644 --- a/packages/payload/src/collections/config/types.ts +++ b/packages/payload/src/collections/config/types.ts @@ -137,9 +137,9 @@ export type RequiredDataFromCollectionSlug = * The id field is optional since it's auto-generated */ export type DraftDataFromCollection = Partial< - Omit + Omit > & - Partial> + Partial> export type DraftDataFromCollectionSlug = DraftDataFromCollection< DataFromCollectionSlug @@ -150,9 +150,9 @@ export type DraftDataFromCollectionSlug = DraftDat * 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 + Omit > & - Partial> & + Partial> & Pick export type QueryDraftDataFromCollectionSlug = diff --git a/packages/payload/src/fields/config/reservedFieldNames.ts b/packages/payload/src/fields/config/reservedFieldNames.ts index 8dd7f32267b..1bdaa45511e 100644 --- a/packages/payload/src/fields/config/reservedFieldNames.ts +++ b/packages/payload/src/fields/config/reservedFieldNames.ts @@ -37,7 +37,7 @@ export const reservedBaseUploadFieldNames = [ 'url', 'focalX', 'focalY', - 'sizes', */ + 'variants', */ ] /** diff --git a/packages/payload/src/uploads/deleteAssociatedFiles.spec.ts b/packages/payload/src/uploads/deleteAssociatedFiles.spec.ts index 2ba6eafaa7f..991fadde354 100644 --- a/packages/payload/src/uploads/deleteAssociatedFiles.spec.ts +++ b/packages/payload/src/uploads/deleteAssociatedFiles.spec.ts @@ -62,7 +62,7 @@ describe('deleteAssociatedFiles', () => { deleteAssociatedFiles( getArgs({ filename, - sizes: { + variants: { preview: { filename: path.join('..', generatedFilename) }, }, }), @@ -86,7 +86,7 @@ describe('deleteAssociatedFiles', () => { await deleteAssociatedFiles( getArgs({ filename, - sizes: { + variants: { preview: { filename: generatedFilename }, }, }), @@ -104,7 +104,7 @@ describe('deleteAssociatedFiles', () => { await deleteAssociatedFiles( getArgs({ filename, - sizes: { + variants: { preview: { filename: null }, }, }), diff --git a/packages/payload/src/uploads/deleteAssociatedFiles.ts b/packages/payload/src/uploads/deleteAssociatedFiles.ts index 5767b9aade9..0e01cf69bf2 100644 --- a/packages/payload/src/uploads/deleteAssociatedFiles.ts +++ b/packages/payload/src/uploads/deleteAssociatedFiles.ts @@ -43,8 +43,8 @@ export const deleteAssociatedFiles: (args: Args) => Promise = async ({ throw new ErrorDeletingFile(req.t) } - if (doc.sizes) { - const sizes: FileData[] = Object.values(doc.sizes) + if (doc.variants) { + const sizes: FileData[] = Object.values(doc.variants) // Since forEach will not wait until unlink is finished it could // happen that two operations will try to delete the same file. // To avoid this it is recommended to use "sync" instead diff --git a/packages/payload/src/uploads/docWithFilenameExists.ts b/packages/payload/src/uploads/docWithFilenameExists.ts index 7bc13768dae..dd2557deaf1 100644 --- a/packages/payload/src/uploads/docWithFilenameExists.ts +++ b/packages/payload/src/uploads/docWithFilenameExists.ts @@ -30,7 +30,7 @@ export const docWithFilenameExists = async ({ }, ...(upload && typeof upload === 'object' && upload.variants ? upload.variants.map(({ name }) => ({ - [`sizes.${name}.filename`]: { equals: filename }, + [`variants.${name}.filename`]: { equals: filename }, })) : []), ], diff --git a/packages/payload/src/uploads/generateFileData.ts b/packages/payload/src/uploads/generateFileData.ts index f3a28c4ba53..e11778a3b01 100644 --- a/packages/payload/src/uploads/generateFileData.ts +++ b/packages/payload/src/uploads/generateFileData.ts @@ -445,7 +445,7 @@ export const generateFileData = async ({ const { name: baseName, ext: baseExt } = parseFilename(fsSafeName) for (const result of sizeResults) { - const sizeName = result.fieldPath.slice('sizes.'.length) + const sizeName = result.fieldPath.slice('variants.'.length) if (!result.file) { sizes[sizeName] = { @@ -502,7 +502,7 @@ export const generateFileData = async ({ }) } - fileData.sizes = sizes + fileData.variants = sizes } } catch (err) { req.payload.logger.error(err) diff --git a/packages/payload/src/uploads/getBaseFields.ts b/packages/payload/src/uploads/getBaseFields.ts index 36e0794a2cc..85c8638e6e7 100644 --- a/packages/payload/src/uploads/getBaseFields.ts +++ b/packages/payload/src/uploads/getBaseFields.ts @@ -61,13 +61,13 @@ export const getBaseUploadFields = ({ collection, config }: Options): Field[] => config, filename: typeof adminThumbnail === 'string' - ? (originalDoc.sizes?.[adminThumbnail]?.filename as string) + ? (originalDoc.variants?.[adminThumbnail]?.filename as string) : undefined, relative: false, serverURL: req.payload.config.serverURL, urlOrPath: typeof adminThumbnail === 'string' - ? (originalDoc.sizes?.[adminThumbnail]?.url as string) + ? (originalDoc.variants?.[adminThumbnail]?.url as string) : undefined, }) }, @@ -200,7 +200,7 @@ export const getBaseUploadFields = ({ collection, config }: Options): Field[] => if (uploadOptions.variants) { uploadFields = uploadFields.concat([ { - name: 'sizes', + name: 'variants', type: 'group', admin: { hidden: true, @@ -226,8 +226,8 @@ export const getBaseUploadFields = ({ collection, config }: Options): Field[] => collectionSlug: collection?.slug as string, config, filename: - data?.sizes?.[size.name]?.filename || - originalDoc?.sizes?.[size.name]?.filename, + data?.variants?.[size.name]?.filename || + originalDoc?.variants?.[size.name]?.filename, relative: false, serverURL: req.payload.config.serverURL, urlOrPath: value, @@ -239,8 +239,8 @@ export const getBaseUploadFields = ({ collection, config }: Options): Field[] => collectionSlug: collection?.slug as string, config, filename: - data?.sizes?.[size.name]?.filename || - originalDoc?.sizes?.[size.name]?.filename, + data?.variants?.[size.name]?.filename || + originalDoc?.variants?.[size.name]?.filename, relative: true, serverURL: req.payload.config.serverURL, urlOrPath: value, diff --git a/packages/payload/src/uploads/getFileFromUploadInstructions.spec.ts b/packages/payload/src/uploads/getFileFromUploadInstructions.spec.ts index 1ecf7930bf1..33567771cc8 100644 --- a/packages/payload/src/uploads/getFileFromUploadInstructions.spec.ts +++ b/packages/payload/src/uploads/getFileFromUploadInstructions.spec.ts @@ -232,7 +232,7 @@ describe('getFileFromUploadInstructions', () => { where: { or: [ { filename: { equals: 'preview.png' } }, - { 'sizes.preview.filename': { equals: 'preview.png' } }, + { 'variants.preview.filename': { equals: 'preview.png' } }, ], }, }), diff --git a/packages/payload/src/uploads/sanitizeUploadData.spec.ts b/packages/payload/src/uploads/sanitizeUploadData.spec.ts index c60716a4399..becac498509 100644 --- a/packages/payload/src/uploads/sanitizeUploadData.spec.ts +++ b/packages/payload/src/uploads/sanitizeUploadData.spec.ts @@ -16,7 +16,7 @@ describe('sanitizeUploadData', () => { getExternalUploadSource({ alt: 'Alternative text', filename: 'remote.png', - sizes: { thumbnail: { filename: 'submitted-thumbnail.png' } }, + variants: { thumbnail: { filename: 'submitted-thumbnail.png' } }, url: 'https://example.com/remote.png', }), ).toEqual({ @@ -32,7 +32,7 @@ describe('sanitizeUploadData', () => { alt: 'Updated alternative text', filename: 'submitted.png', prefix: 'articles', - sizes: { + variants: { thumbnail: { filename: 'submitted-thumbnail.png', height: 100, @@ -99,7 +99,7 @@ describe('sanitizeUploadData', () => { { filename: 'C:\\submitted.png' }, { filename: '\\\\server\\submitted.png' }, { filename: 'images/../submitted.png' }, - { sizes: { thumbnail: { filename: '..\\submitted-thumbnail.png' } } }, + { variants: { thumbnail: { filename: '..\\submitted-thumbnail.png' } } }, ])('should reject an invalid submitted filename', (data) => { expect(() => sanitizeUploadData(data, 'update')).toThrow('Invalid filename.') }) @@ -113,7 +113,7 @@ describe('mergeUploadDataWithDocument', () => { { filename: 'stored.png', prefix: 'articles', - sizes: { + variants: { thumbnail: { filename: 'stored-thumbnail.png', height: 100, @@ -127,7 +127,7 @@ describe('mergeUploadDataWithDocument', () => { alt: 'Updated alternative text', filename: 'stored.png', prefix: 'articles', - sizes: { + variants: { thumbnail: { filename: 'stored-thumbnail.png', height: 100, @@ -144,13 +144,13 @@ describe('mergeUploadDataWithDocument', () => { { filename: 'hook.png', prefix: 'hook-prefix', - sizes: { thumbnail: { filename: 'hook-thumbnail.png' } }, + variants: { thumbnail: { filename: 'hook-thumbnail.png' } }, url: '/api/media/file/hook.png', }, { filename: 'stored.png', prefix: 'stored-prefix', - sizes: { + variants: { thumbnail: { filename: 'stored-thumbnail.png', height: 100, width: 100 }, }, url: '/api/media/file/stored.png', @@ -159,7 +159,7 @@ describe('mergeUploadDataWithDocument', () => { ).toEqual({ filename: 'hook.png', prefix: 'hook-prefix', - sizes: { + variants: { thumbnail: { filename: 'hook-thumbnail.png', height: 100, width: 100 }, }, url: '/api/media/file/hook.png', diff --git a/packages/payload/src/uploads/sanitizeUploadData.ts b/packages/payload/src/uploads/sanitizeUploadData.ts index 8ca26b2e514..49758a8340c 100644 --- a/packages/payload/src/uploads/sanitizeUploadData.ts +++ b/packages/payload/src/uploads/sanitizeUploadData.ts @@ -114,8 +114,8 @@ export const sanitizeUploadData = (data: T, operation: Operation): T => { validateFilename(data.filename) - if (isRecord(data.sizes)) { - for (const sizeData of Object.values(data.sizes)) { + if (isRecord(data.variants)) { + for (const sizeData of Object.values(data.variants)) { if (isRecord(sizeData)) { validateFilename(sizeData.filename) } @@ -124,7 +124,7 @@ export const sanitizeUploadData = (data: T, operation: Operation): T => { const sanitizedData: Record = { ...data } delete sanitizedData.filename - delete sanitizedData.sizes + delete sanitizedData.variants delete sanitizedData.url // Server-owned; never accepted from the caller. delete sanitizedData._objectKey @@ -138,7 +138,7 @@ export const sanitizeUploadData = (data: T, operation: Operation): T => { } // Restores only the identity fields that select/serve a stored object (`filename`, `prefix`, -// `url`, `sizes`). Other generated-but-derived fields (`thumbnailURL`, `filesize`, `height`, +// `url`, `variants`). Other generated-but-derived fields (`thumbnailURL`, `filesize`, `height`, // `width`, `mimeType`, `focalX`/`focalY`) are intentionally left as submitted: `focalX`/`focalY` // are user-editable crop input, and the rest are regenerated by generateFileData when a file is // written. Compare with restoreUploadDataFromDocument (version restore), which re-binds the full @@ -160,20 +160,20 @@ export const mergeUploadDataWithDocument = ( } } - const documentSizes = getDocumentProperty(document, 'sizes', options) - if (!hasOwnProperty(data, 'sizes') && hasOwnProperty(document, 'sizes')) { - mergedData.sizes = documentSizes - } else if (isRecord(data.sizes) && isRecord(documentSizes)) { + const documentSizes = getDocumentProperty(document, 'variants', options) + if (!hasOwnProperty(data, 'variants') && hasOwnProperty(document, 'variants')) { + mergedData.variants = documentSizes + } else if (isRecord(data.variants) && isRecord(documentSizes)) { const mergedSizes: Record = { ...documentSizes } - for (const [sizeName, sizeData] of Object.entries(data.sizes)) { + for (const [sizeName, sizeData] of Object.entries(data.variants)) { const documentSize = documentSizes[sizeName] mergedSizes[sizeName] = isRecord(sizeData) && isRecord(documentSize) ? { ...documentSize, ...sizeData } : sizeData } - mergedData.sizes = mergedSizes + mergedData.variants = mergedSizes } return mergedData as T @@ -188,7 +188,7 @@ const uploadDerivedProperties = [ 'height', 'mimeType', 'prefix', - 'sizes', + 'variants', 'thumbnailURL', 'url', 'width', diff --git a/packages/payload/src/uploads/transformers/resolveUploadDocument.spec.ts b/packages/payload/src/uploads/transformers/resolveUploadDocument.spec.ts index 4796fe1d582..7d81ed34d89 100644 --- a/packages/payload/src/uploads/transformers/resolveUploadDocument.spec.ts +++ b/packages/payload/src/uploads/transformers/resolveUploadDocument.spec.ts @@ -23,7 +23,7 @@ describe('getRequestedFile', () => { id: '1', filename: 'logo.png', mimeType: 'image/png', - sizes: { + variants: { card: { filename: 'logo-640x480.webp', mimeType: 'image/webp' }, skipped: { filename: null, mimeType: null }, }, diff --git a/packages/payload/src/uploads/transformers/resolveUploadDocument.ts b/packages/payload/src/uploads/transformers/resolveUploadDocument.ts index 5511d580ef3..527f9e57b8b 100644 --- a/packages/payload/src/uploads/transformers/resolveUploadDocument.ts +++ b/packages/payload/src/uploads/transformers/resolveUploadDocument.ts @@ -6,7 +6,7 @@ import { Forbidden } from '../../errors/Forbidden.js' export type ResolvedUploadDocument = { filename: string mimeType: string - sizes?: Record + variants?: Record } & TypeWithID /** @@ -26,7 +26,7 @@ export function buildFilenameWhere({ variants?.forEach(({ name }) => { filenameCondition.or!.push({ - [`sizes.${name}.filename`]: { equals: filename }, + [`variants.${name}.filename`]: { equals: filename }, }) }) @@ -46,7 +46,7 @@ export function getRequestedFile({ filename: string }): { filename: string; mimeType: string } { if (document.filename !== filename) { - const size = Object.values(document.sizes ?? {}).find((size) => size?.filename === filename) + const size = Object.values(document.variants ?? {}).find((size) => size?.filename === filename) if (size?.filename && size.mimeType) { return { filename: size.filename, mimeType: size.mimeType } diff --git a/packages/payload/src/uploads/transformers/uploadTransformerBridge.ts b/packages/payload/src/uploads/transformers/uploadTransformerBridge.ts index 532229048f3..a7047ef582a 100644 --- a/packages/payload/src/uploads/transformers/uploadTransformerBridge.ts +++ b/packages/payload/src/uploads/transformers/uploadTransformerBridge.ts @@ -4,22 +4,22 @@ import type { UploadEdits } from '../types.js' /** * Private v4 compatibility bridge, attached to a transformer object under this * symbol. Lets the official Sharp package reproduce the current main-image and - * legacy `sizes` upload behavior without core exposing a general persisted- + * legacy `variants` upload behavior without core exposing a general persisted- * variants API. Never exported from `payload` — `payload/internal` only, and * never documented for third-party use. */ export const uploadTransformerInternal = Symbol.for('payload.uploadTransformerInternal') export type UploadTransformTask = { - fieldPath: 'filename' | `sizes.${string}` + fieldPath: 'filename' | `variants.${string}` /** Source file for this task. Defaults to the original upload (e.g. pass the cropped main output so sizes derive from the crop). */ file?: File options: TOptions } export type PreparedUploadTransformation = { - fieldPath: 'filename' | `sizes.${string}` - /** Omitted when this task was intentionally skipped (e.g. an image size too small to enlarge) — recorded with null metadata, matching the existing `sizes` shape. */ + fieldPath: 'filename' | `variants.${string}` + /** Omitted when this task was intentionally skipped (e.g. an image size too small to enlarge) — recorded with null metadata, matching the existing `variants` shape. */ file?: File height?: number mimeType?: string diff --git a/packages/payload/src/uploads/types.ts b/packages/payload/src/uploads/types.ts index dbfbaec4976..6e283eb15cd 100644 --- a/packages/payload/src/uploads/types.ts +++ b/packages/payload/src/uploads/types.ts @@ -23,9 +23,9 @@ export type FileData = { focalY?: number height: number mimeType: string - sizes: FileSizes tempFilePath?: string url?: string + variants: FileSizes width: number } diff --git a/packages/payload/src/utilities/appendUploadSelectFields.ts b/packages/payload/src/utilities/appendUploadSelectFields.ts index ea9854d49a9..443f1eee961 100644 --- a/packages/payload/src/utilities/appendUploadSelectFields.ts +++ b/packages/payload/src/utilities/appendUploadSelectFields.ts @@ -29,14 +29,14 @@ export const appendUploadSelectFields = ({ typeof collectionConfig.upload.adminThumbnail === 'string' ) { /** Only return image size properties that are required to generate the adminThumbnailURL */ - select.sizes = { + select.variants = { [collectionConfig.upload.adminThumbnail]: { filename: true, }, } } else { /** Only return image size properties that are required for thumbnails */ - select.sizes = collectionConfig.upload.variants.reduce((acc, imageSizeConfig) => { + select.variants = collectionConfig.upload.variants.reduce((acc, imageSizeConfig) => { return { ...acc, [imageSizeConfig.name]: { diff --git a/packages/plugin-cloud-storage/src/fields/getFields.ts b/packages/plugin-cloud-storage/src/fields/getFields.ts index d967b94be6a..833cbb254d0 100644 --- a/packages/plugin-cloud-storage/src/fields/getFields.ts +++ b/packages/plugin-cloud-storage/src/fields/getFields.ts @@ -118,7 +118,7 @@ export const getFields = ({ let existingSizesFieldIndex = -1 const existingSizesField = fields.find((existingField, i) => { - if ('name' in existingField && existingField.name === 'sizes') { + if ('name' in existingField && existingField.name === 'variants') { existingSizesFieldIndex = i return true } @@ -132,7 +132,7 @@ export const getFields = ({ const sizesField: Field = { ...(existingSizesField || {}), - name: 'sizes', + name: 'variants', type: 'group', admin: { hidden: true, diff --git a/packages/plugin-cloud-storage/src/hooks/afterChange.spec.ts b/packages/plugin-cloud-storage/src/hooks/afterChange.spec.ts index dbca2963db4..19003db0257 100644 --- a/packages/plugin-cloud-storage/src/hooks/afterChange.spec.ts +++ b/packages/plugin-cloud-storage/src/hooks/afterChange.spec.ts @@ -22,7 +22,7 @@ describe('upload replacement cleanup', () => { id: 1, filename: 'file.png', mimeType: 'image/png', - sizes: { thumbnail: { filename: 'thumb.png' } }, + variants: { thumbnail: { filename: 'thumb.png' } }, prefix: newPrefix, } @@ -34,7 +34,7 @@ describe('upload replacement cleanup', () => { id: 1, filename: 'file.png', mimeType: 'image/png', - sizes: { thumbnail: { filename: 'thumb.png' } }, + variants: { thumbnail: { filename: 'thumb.png' } }, prefix: oldPrefix, }, operation: 'update', diff --git a/packages/plugin-cloud-storage/src/hooks/afterChange.ts b/packages/plugin-cloud-storage/src/hooks/afterChange.ts index cf69ccf727e..545afba8c2b 100644 --- a/packages/plugin-cloud-storage/src/hooks/afterChange.ts +++ b/packages/plugin-cloud-storage/src/hooks/afterChange.ts @@ -125,9 +125,9 @@ export const getAfterChangeHook = filesToDelete.push(previousDoc.filename) } - if (typeof previousDoc.sizes === 'object') { + if (typeof previousDoc.variants === 'object') { filesToDelete = filesToDelete.concat( - Object.values(previousDoc?.sizes || []).map( + Object.values(previousDoc?.variants || []).map( (resizedFileData) => resizedFileData?.filename as string, ), ) @@ -140,8 +140,8 @@ export const getAfterChangeHook = if (typeof newFileData.filename === 'string') { newFilenames.add(newFileData.filename) } - if (typeof newFileData.sizes === 'object') { - for (const size of Object.values(newFileData.sizes || {})) { + if (typeof newFileData.variants === 'object') { + for (const size of Object.values(newFileData.variants || {})) { if (size?.filename && typeof size.filename === 'string') { newFilenames.add(size.filename) } diff --git a/packages/plugin-cloud-storage/src/hooks/afterDelete.ts b/packages/plugin-cloud-storage/src/hooks/afterDelete.ts index 03d674f3812..fae6144eb00 100644 --- a/packages/plugin-cloud-storage/src/hooks/afterDelete.ts +++ b/packages/plugin-cloud-storage/src/hooks/afterDelete.ts @@ -28,7 +28,7 @@ export const getAfterDeleteHook = ({ const filesToDelete: string[] = [ doc.filename, - ...Object.values(doc?.sizes || []).map( + ...Object.values(doc?.variants || []).map( (resizedFileData) => resizedFileData?.filename as string, ), ] diff --git a/packages/plugin-cloud-storage/src/hooks/afterRead.ts b/packages/plugin-cloud-storage/src/hooks/afterRead.ts index 0de552ae75f..fef51fd3b3e 100644 --- a/packages/plugin-cloud-storage/src/hooks/afterRead.ts +++ b/packages/plugin-cloud-storage/src/hooks/afterRead.ts @@ -30,7 +30,7 @@ const getObjectFolder = (data: unknown): string => { export const getAfterReadHook = ({ adapter, collection, disablePayloadAccessControl, generateFileURL, size }: Args): FieldHook => async ({ data, value }) => { - const filename = size ? data?.sizes?.[size.name]?.filename : data?.filename + const filename = size ? data?.variants?.[size.name]?.filename : data?.filename const prefix = data?.prefix // Direct-serve URLs encode the full location; the proxy resolves `_objectKey` server-side. const objectFolder = getObjectFolder(data) diff --git a/packages/plugin-cloud-storage/src/hooks/beforeChange.ts b/packages/plugin-cloud-storage/src/hooks/beforeChange.ts index e8fa571c60c..c7eecba2f8f 100644 --- a/packages/plugin-cloud-storage/src/hooks/beforeChange.ts +++ b/packages/plugin-cloud-storage/src/hooks/beforeChange.ts @@ -30,9 +30,9 @@ const getObjectFolder = (data: unknown, originalDoc: unknown): string => { export const getBeforeChangeHook = ({ adapter, collection, disablePayloadAccessControl, generateFileURL, size }: Args): FieldHook => async ({ data, originalDoc, value }) => { - const newFilename = size ? data?.sizes?.[size.name]?.filename : data?.filename + const newFilename = size ? data?.variants?.[size.name]?.filename : data?.filename const originalFilename = size - ? originalDoc?.sizes?.[size.name]?.filename + ? originalDoc?.variants?.[size.name]?.filename : originalDoc?.filename const filename = newFilename || originalFilename const prefix = getObjectFolder(data, originalDoc) diff --git a/packages/plugin-cloud-storage/src/utilities/getFilePrefix.ts b/packages/plugin-cloud-storage/src/utilities/getFilePrefix.ts index 4bdf5bc02c8..8fecb1520e2 100644 --- a/packages/plugin-cloud-storage/src/utilities/getFilePrefix.ts +++ b/packages/plugin-cloud-storage/src/utilities/getFilePrefix.ts @@ -72,7 +72,7 @@ export async function getFilePrefix({ filename: { equals: filename }, }, ...variants.map((imageSize) => ({ - [`sizes.${imageSize.name}.filename`]: { equals: filename }, + [`variants.${imageSize.name}.filename`]: { equals: filename }, })), ], } diff --git a/packages/plugin-cloud-storage/src/utilities/getIncomingFiles.ts b/packages/plugin-cloud-storage/src/utilities/getIncomingFiles.ts index 146982e7f23..3ab4d06c965 100644 --- a/packages/plugin-cloud-storage/src/utilities/getIncomingFiles.ts +++ b/packages/plugin-cloud-storage/src/utilities/getIncomingFiles.ts @@ -33,8 +33,8 @@ export function getIncomingFiles({ files = [mainFile] - if (data?.sizes) { - Object.entries(data.sizes).forEach(([key, resizedFileData]) => { + if (data?.variants) { + Object.entries(data.variants).forEach(([key, resizedFileData]) => { if (payloadUploadSizes?.[key] && resizedFileData.mimeType) { files = files.concat([ { diff --git a/packages/richtext-lexical/src/features/converters/lexicalToHtml/async/converters/upload.ts b/packages/richtext-lexical/src/features/converters/lexicalToHtml/async/converters/upload.ts index 75ed09ff54b..bbf3eca442b 100644 --- a/packages/richtext-lexical/src/features/converters/lexicalToHtml/async/converters/upload.ts +++ b/packages/richtext-lexical/src/features/converters/lexicalToHtml/async/converters/upload.ts @@ -41,7 +41,7 @@ export const UploadHTMLConverterAsync: HTMLConvertersAsync } // 2) If image has no different sizes, return a simple - if (!uploadDoc.sizes || !Object.keys(uploadDoc.sizes).length) { + if (!uploadDoc.variants || !Object.keys(uploadDoc.variants).length) { return ` // 3) If image has different sizes, build a element with tags let pictureHTML = '' - for (const size in uploadDoc.sizes) { - const imageSize = uploadDoc.sizes[size] as FileSize + for (const size in uploadDoc.variants) { + const imageSize = uploadDoc.variants[size] as FileSize if ( !imageSize || diff --git a/packages/richtext-lexical/src/features/converters/lexicalToHtml/sync/converters/upload.ts b/packages/richtext-lexical/src/features/converters/lexicalToHtml/sync/converters/upload.ts index d63b8807ed4..4b1896fc552 100644 --- a/packages/richtext-lexical/src/features/converters/lexicalToHtml/sync/converters/upload.ts +++ b/packages/richtext-lexical/src/features/converters/lexicalToHtml/sync/converters/upload.ts @@ -35,7 +35,7 @@ export const UploadHTMLConverter: HTMLConverters = { } // 2) If image has no different sizes, return a simple - if (!uploadDoc.sizes || !Object.keys(uploadDoc.sizes).length) { + if (!uploadDoc.variants || !Object.keys(uploadDoc.variants).length) { return ` = { // 3) If image has different sizes, build a element with tags let pictureHTML = '' - for (const size in uploadDoc.sizes) { - const imageSize = uploadDoc.sizes[size] as FileSize + for (const size in uploadDoc.variants) { + const imageSize = uploadDoc.variants[size] as FileSize if ( !imageSize || diff --git a/packages/richtext-lexical/src/features/converters/lexicalToJSX/converter/converters/upload.tsx b/packages/richtext-lexical/src/features/converters/lexicalToJSX/converter/converters/upload.tsx index d9ba171cce9..e4381b90941 100644 --- a/packages/richtext-lexical/src/features/converters/lexicalToJSX/converter/converters/upload.tsx +++ b/packages/richtext-lexical/src/features/converters/lexicalToJSX/converter/converters/upload.tsx @@ -32,7 +32,7 @@ export const UploadJSXConverter: JSXConverters = { /** * If the upload is a simple image with no different sizes, return a simple img tag */ - if (!uploadDoc.sizes || !Object.keys(uploadDoc.sizes).length) { + if (!uploadDoc.variants || !Object.keys(uploadDoc.variants).length) { return {alt} } @@ -41,9 +41,9 @@ export const UploadJSXConverter: JSXConverters = { */ const pictureJSX: React.ReactNode[] = [] - // Iterate through each size in the data.sizes object - for (const size in uploadDoc.sizes) { - const imageSize = uploadDoc.sizes[size] as FileSize + // Iterate through each size in the data.variants object + for (const size in uploadDoc.variants) { + const imageSize = uploadDoc.variants[size] as FileSize // Skip if any property of the size object is null if ( diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index ab2c8b067cb..bed2d2121ac 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -65,7 +65,7 @@ export type TransformGlobalWithSelect< > : T['globals'][TSlug] -type SystemFields = 'createdAt' | 'id' | 'sizes' | 'updatedAt' +type SystemFields = 'createdAt' | 'id' | 'updatedAt' | 'variants' export type RequiredDataFromCollection = Omit & Partial & TData, SystemFields>> diff --git a/packages/storage-vercel-blob/src/authorizeFileOverwrite.ts b/packages/storage-vercel-blob/src/authorizeFileOverwrite.ts index 9f1d682b9aa..78b129f2e56 100644 --- a/packages/storage-vercel-blob/src/authorizeFileOverwrite.ts +++ b/packages/storage-vercel-blob/src/authorizeFileOverwrite.ts @@ -28,7 +28,7 @@ function getDocumentFileKeys({ doc: OwnerDocument useCompositePrefixes: boolean }): string[] { - return [doc.filename, ...Object.values(doc.sizes || {}).map((size) => size?.filename)] + return [doc.filename, ...Object.values(doc.variants || {}).map((size) => size?.filename)] .filter((filename): filename is string => typeof filename === 'string') .map( (filename) => @@ -69,7 +69,7 @@ async function findMatchingDocs({ const filenameQueries: Where[] = [ { filename: { equals: requestedFilename } }, ...variants.map(({ name }) => ({ - [`sizes.${name}.filename`]: { equals: requestedFilename }, + [`variants.${name}.filename`]: { equals: requestedFilename }, })), ] const result = await req.payload.find({ diff --git a/packages/transformer-sharp/README.md b/packages/transformer-sharp/README.md index ea4caa434df..1cc31e8427b 100644 --- a/packages/transformer-sharp/README.md +++ b/packages/transformer-sharp/README.md @@ -47,7 +47,7 @@ sharpTransformer({ }) ``` -`variants`, `crop`, and `focalPoint` are also written back onto the collection's own sanitized `upload` config at startup (name/`admin`/`generateImageName` only, for `variants`), so the Admin Panel, generated types, and the `sizes` shape on your documents behave exactly as before. +`variants`, `crop`, and `focalPoint` are also written back onto the collection's own sanitized `upload` config at startup (name/`admin`/`generateImageName` only, for `variants`), so the Admin Panel and generated types pick them up. Generated files are stored under each document's `variants` field. ### Dynamic (request-time) resizing diff --git a/packages/transformer-sharp/src/initSharpCollections.ts b/packages/transformer-sharp/src/initSharpCollections.ts index 88a9fe3a97e..09ca403fcb4 100644 --- a/packages/transformer-sharp/src/initSharpCollections.ts +++ b/packages/transformer-sharp/src/initSharpCollections.ts @@ -10,7 +10,7 @@ const RESERVED_IMAGE_SIZE_NAMES = [ 'height', 'url', 'thumbnailURL', - 'sizes', + 'variants', 'focalX', 'focalY', ] diff --git a/packages/transformer-sharp/src/prepareLegacyUpload.ts b/packages/transformer-sharp/src/prepareLegacyUpload.ts index daaca3eb47c..b1cbecae6dd 100644 --- a/packages/transformer-sharp/src/prepareLegacyUpload.ts +++ b/packages/transformer-sharp/src/prepareLegacyUpload.ts @@ -109,7 +109,7 @@ export function createPrepareLegacyUpload({ const sizeResults = await mapWithBoundedConcurrency(variants, async (rawConfig) => { const imageResizeConfig = sanitizeResizeConfig(rawConfig) - const fieldPath = `sizes.${imageResizeConfig.name}` as const + const fieldPath = `variants.${imageResizeConfig.name}` as const const resizeAction = getImageResizeAction({ dimensions: sizeSourceDimensions, diff --git a/packages/transformer-sharp/src/types.ts b/packages/transformer-sharp/src/types.ts index 144d2351b76..535e8fa67e1 100644 --- a/packages/transformer-sharp/src/types.ts +++ b/packages/transformer-sharp/src/types.ts @@ -37,7 +37,7 @@ export type SharpImageSizeOptions = { crop?: string // comes from sharp package formatOptions?: ImageUploadFormatOptions trimOptions?: ImageUploadTrimOptions - /** Image sizes generated for each upload, stored under the document's `sizes`. */ + /** Image sizes generated for each upload, stored under the document's `variants`. */ variants?: ImageSize[] /** * When an uploaded image is smaller than the defined image size, we have 3 options: @@ -71,7 +71,7 @@ export type SharpCollectionConfig = { formatOptions?: ImageUploadFormatOptions resizeOptions?: ResizeOptions trimOptions?: ImageUploadTrimOptions - /** Image sizes generated for each upload, stored under the document's `sizes`. */ + /** Image sizes generated for each upload, stored under the document's `variants`. */ variants?: ImageSize[] withMetadata?: WithMetadata } diff --git a/packages/ui/src/elements/FileDetails/DraggableFileDetails/index.tsx b/packages/ui/src/elements/FileDetails/DraggableFileDetails/index.tsx index b4e2d853f40..197dc1f1a0a 100644 --- a/packages/ui/src/elements/FileDetails/DraggableFileDetails/index.tsx +++ b/packages/ui/src/elements/FileDetails/DraggableFileDetails/index.tsx @@ -18,7 +18,7 @@ export type DraggableFileDetailsProps = { collectionSlug: string customUploadActions?: React.ReactNode[] doc: { - sizes?: FileSizes + variants?: FileSizes } & Data enableAdjustments?: boolean hasImageSizes?: boolean diff --git a/packages/ui/src/elements/FileDetails/FileMeta/index.tsx b/packages/ui/src/elements/FileDetails/FileMeta/index.tsx index d410660534e..176eaa6d52e 100644 --- a/packages/ui/src/elements/FileDetails/FileMeta/index.tsx +++ b/packages/ui/src/elements/FileDetails/FileMeta/index.tsx @@ -7,8 +7,8 @@ export type FileMetaProps = { filesize: number height?: number mimeType: string - sizes?: unknown url: string + variants?: unknown width?: number } diff --git a/packages/ui/src/elements/FileDetails/StaticFileDetails/index.tsx b/packages/ui/src/elements/FileDetails/StaticFileDetails/index.tsx index 045d6597b07..e5c2e472003 100644 --- a/packages/ui/src/elements/FileDetails/StaticFileDetails/index.tsx +++ b/packages/ui/src/elements/FileDetails/StaticFileDetails/index.tsx @@ -14,7 +14,7 @@ import type { Data, FileSizes, SanitizedCollectionConfig } from 'payload' export type StaticFileDetailsProps = { customUploadActions?: React.ReactNode[] doc: { - sizes?: FileSizes + variants?: FileSizes } & Data enableAdjustments?: boolean handleRemove?: () => void diff --git a/packages/ui/src/elements/FileDetails/index.tsx b/packages/ui/src/elements/FileDetails/index.tsx index 9e9df479c93..db6f4c257b8 100644 --- a/packages/ui/src/elements/FileDetails/index.tsx +++ b/packages/ui/src/elements/FileDetails/index.tsx @@ -10,7 +10,7 @@ type SharedFileDetailsProps = { collectionSlug: string customUploadActions?: React.ReactNode[] doc: { - sizes?: FileSizes + variants?: FileSizes } & Data enableAdjustments?: boolean hasImageSizes?: boolean diff --git a/packages/ui/src/elements/FileManager/index.tsx b/packages/ui/src/elements/FileManager/index.tsx index 53ac04ef0dc..ecdeac005a2 100644 --- a/packages/ui/src/elements/FileManager/index.tsx +++ b/packages/ui/src/elements/FileManager/index.tsx @@ -115,7 +115,7 @@ export const FileManager: React.FC = ({ const showFocalPoint = focalPoint && (hasVariants || hasImageAdjustments || focalPointEnabled) const selectedSizeData = selectedSize - ? (data?.sizes?.[selectedSize] as Record) + ? (data?.variants?.[selectedSize] as Record) : null const sidePanelFileSrc = (selectedSizeData?.url ?? data?.thumbnailURL ?? data?.url ?? null) as | null diff --git a/packages/ui/src/elements/MiniCarousel/index.tsx b/packages/ui/src/elements/MiniCarousel/index.tsx index a56cc3dd37b..a84f50629df 100644 --- a/packages/ui/src/elements/MiniCarousel/index.tsx +++ b/packages/ui/src/elements/MiniCarousel/index.tsx @@ -43,8 +43,8 @@ const MiniCarouselItem: React.FC = ({ export type MiniCarouselProps = { doc: { - sizes?: FileSizes url?: string + variants?: FileSizes } & Data imageCacheTag?: false | string onSelect: (sizeKey: null | string) => void @@ -61,7 +61,7 @@ export const MiniCarousel: React.FC = ({ }) => { const { t } = useTranslation() const { variants } = uploadConfig - const { sizes, url: originalUrl } = doc + const { url: originalUrl, variants: sizes } = doc const orderedSizeKeys = React.useMemo(() => { if (!variants || variants.length === 0) { diff --git a/packages/ui/src/elements/PreviewSizes/index.tsx b/packages/ui/src/elements/PreviewSizes/index.tsx index b0003d0d59e..11f87baf8e3 100644 --- a/packages/ui/src/elements/PreviewSizes/index.tsx +++ b/packages/ui/src/elements/PreviewSizes/index.tsx @@ -78,7 +78,7 @@ const PreviewSizeCard: React.FC = ({ export type PreviewSizesProps = { doc: { - sizes?: FilesSizesWithUrl + variants?: FilesSizesWithUrl } & Data imageCacheTag?: false | string uploadConfig: SanitizedCollectionConfig['upload'] @@ -86,7 +86,7 @@ export type PreviewSizesProps = { export const PreviewSizes: React.FC = ({ doc, imageCacheTag, uploadConfig }) => { const { variants } = uploadConfig - const { sizes } = doc + const { variants: sizes } = doc const alt = (doc as { alt?: string })?.alt || doc.filename || '' @@ -108,7 +108,7 @@ export const PreviewSizes: React.FC = ({ doc, imageCacheTag, }, [sizes, variants, imageCacheTag]) const mainPreviewSrc = selectedSize - ? generateImageUrl(doc.sizes[selectedSize]) + ? generateImageUrl(doc.variants[selectedSize]) : generateImageUrl(doc) const originalImage = useMemo( diff --git a/packages/ui/src/elements/Table/DefaultCell/fields/File/index.tsx b/packages/ui/src/elements/Table/DefaultCell/fields/File/index.tsx index 0abfe207963..a909a66ef52 100644 --- a/packages/ui/src/elements/Table/DefaultCell/fields/File/index.tsx +++ b/packages/ui/src/elements/Table/DefaultCell/fields/File/index.tsx @@ -36,7 +36,7 @@ export const FileCell: React.FC = ({ if (isFileImage) { fileSrc = getBestFitFromSizes({ - sizes: rowData?.sizes, + sizes: rowData?.variants, thumbnailURL: rowData?.thumbnailURL, url: rowData?.url, width: rowData?.width, diff --git a/packages/ui/src/fields/Upload/HasMany/index.tsx b/packages/ui/src/fields/Upload/HasMany/index.tsx index 5647633e841..570bbad982b 100644 --- a/packages/ui/src/fields/Upload/HasMany/index.tsx +++ b/packages/ui/src/fields/Upload/HasMany/index.tsx @@ -102,7 +102,7 @@ export function UploadComponentHasMany(props: Props) { if (isImage(value.mimeType)) { thumbnailSrc = getBestFitFromSizes({ - sizes: value.sizes, + sizes: value.variants, thumbnailURL: thumbnailSrc, url: src, width: value.width, diff --git a/packages/ui/src/fields/Upload/HasOne/index.tsx b/packages/ui/src/fields/Upload/HasOne/index.tsx index 8b1789d53a2..6e0056cb36b 100644 --- a/packages/ui/src/fields/Upload/HasOne/index.tsx +++ b/packages/ui/src/fields/Upload/HasOne/index.tsx @@ -62,7 +62,7 @@ export function UploadComponentHasOne(props: Props) { if (isImage(value.mimeType)) { thumbnailSrc = getBestFitFromSizes({ - sizes: value.sizes, + sizes: value.variants, thumbnailURL: thumbnailSrc, url: src, width: value.width, diff --git a/packages/ui/src/utilities/filterOutUploadFields.ts b/packages/ui/src/utilities/filterOutUploadFields.ts index 25c2f4301bd..ec345b1b6bf 100644 --- a/packages/ui/src/utilities/filterOutUploadFields.ts +++ b/packages/ui/src/utilities/filterOutUploadFields.ts @@ -13,7 +13,7 @@ export const filterOutUploadFields = (fields: FieldWithPathClient[]): FieldWithP 'url', 'focalX', 'focalY', - 'sizes', + 'variants', ] return fields.filter((field) => !baseUploadFieldNames.includes('name' in field && field.name)) diff --git a/templates/website/src/payload-types.ts b/templates/website/src/payload-types.ts index 55295b55282..e764ff34bc3 100644 --- a/templates/website/src/payload-types.ts +++ b/templates/website/src/payload-types.ts @@ -320,7 +320,7 @@ export interface Media { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { thumbnail?: { url?: string | null; width?: number | null; @@ -1175,7 +1175,7 @@ export interface MediaSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { thumbnail?: diff --git a/templates/website/src/utilities/generateMeta.ts b/templates/website/src/utilities/generateMeta.ts index 7395c87d05d..367572bc440 100644 --- a/templates/website/src/utilities/generateMeta.ts +++ b/templates/website/src/utilities/generateMeta.ts @@ -11,7 +11,7 @@ const getImageURL = (image?: Media | Config['db']['defaultIDType'] | null) => { let url = serverUrl + '/website-template-OG.webp' if (image && typeof image === 'object' && 'url' in image) { - const ogUrl = image.sizes?.og?.url + const ogUrl = image.variants?.og?.url url = ogUrl ? serverUrl + ogUrl : serverUrl + image.url } diff --git a/templates/with-vercel-website/src/payload-types.ts b/templates/with-vercel-website/src/payload-types.ts index a4f063aedc8..831d8c25d46 100644 --- a/templates/with-vercel-website/src/payload-types.ts +++ b/templates/with-vercel-website/src/payload-types.ts @@ -304,7 +304,7 @@ export interface Media { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { thumbnail?: { url?: string | null; width?: number | null; @@ -1238,7 +1238,7 @@ export interface MediaSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { thumbnail?: diff --git a/templates/with-vercel-website/src/utilities/generateMeta.ts b/templates/with-vercel-website/src/utilities/generateMeta.ts index 7395c87d05d..367572bc440 100644 --- a/templates/with-vercel-website/src/utilities/generateMeta.ts +++ b/templates/with-vercel-website/src/utilities/generateMeta.ts @@ -11,7 +11,7 @@ const getImageURL = (image?: Media | Config['db']['defaultIDType'] | null) => { let url = serverUrl + '/website-template-OG.webp' if (image && typeof image === 'object' && 'url' in image) { - const ogUrl = image.sizes?.og?.url + const ogUrl = image.variants?.og?.url url = ogUrl ? serverUrl + ogUrl : serverUrl + image.url } diff --git a/test/__helpers/shared/sdk/types.ts b/test/__helpers/shared/sdk/types.ts index b4246f805ba..0b1890e9285 100644 --- a/test/__helpers/shared/sdk/types.ts +++ b/test/__helpers/shared/sdk/types.ts @@ -4,8 +4,8 @@ import type { DeepPartial, MarkOptional } from 'ts-essentials' type CollectionDoc = { createdAt?: string id?: number | string - sizes?: unknown updatedAt?: string + variants?: unknown } type BaseTypes = { @@ -48,7 +48,7 @@ export type CreateArgs< collection: TSlug data: MarkOptional< TGeneratedTypes['collections'][TSlug], - 'createdAt' | 'id' | 'sizes' | 'updatedAt' + 'createdAt' | 'id' | 'updatedAt' | 'variants' > depth?: number disableTransaction?: boolean diff --git a/test/_community/payload-types.ts b/test/_community/payload-types.ts index 5bd0c17ee63..efc9d83db84 100644 --- a/test/_community/payload-types.ts +++ b/test/_community/payload-types.ts @@ -62,24 +62,24 @@ export type SupportedTimezones = | 'Pacific/Fiji'; /** * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "LexicalNodes_C0124FAC". + * via the `definition` "LexicalNodes_FCB16F2F". */ -export type LexicalNodes_C0124FAC = +export type LexicalNodes_FCB16F2F = | SerializedTextNode | SerializedTabNode | SerializedLineBreakNode - | SerializedParagraphNode + | SerializedParagraphNode | SerializedHorizontalRuleNode | SerializedUploadNode<'media'> - | SerializedQuoteNode + | SerializedQuoteNode | SerializedRelationshipNode< 'posts' | 'payload-kv' | 'users' | 'payload-locked-documents' | 'payload-preferences' | 'payload-migrations' > - | SerializedAutoLinkNode - | SerializedLinkNode - | SerializedListNode - | SerializedListItemNode - | SerializedHeadingNode; + | SerializedAutoLinkNode + | SerializedLinkNode + | SerializedListNode + | SerializedListItemNode + | SerializedHeadingNode; export interface Config { auth: { @@ -106,7 +106,7 @@ export interface Config { 'payload-migrations': PayloadMigrationsSelect | PayloadMigrationsSelect; }; db: { - defaultIDType: string; + defaultIDType: number; }; fallbackLocale: null; globals: { @@ -150,16 +150,16 @@ export interface UserAuthOperations { * via the `definition` "posts". */ export interface Post { - id: string; + id: number; title?: string | null; - content?: LexicalRichText | null; + content?: LexicalRichText | null; createdBy?: { relationTo: 'users'; - value: string | User; + value: number | User; } | null; updatedBy?: { relationTo: 'users'; - value: string | User; + value: number | User; } | null; updatedAt: string; createdAt: string; @@ -169,14 +169,14 @@ export interface Post { * via the `definition` "users". */ export interface User { - id: string; + id: number; createdBy?: { relationTo: 'users'; - value: string | User; + value: number | User; } | null; updatedBy?: { relationTo: 'users'; - value: string | User; + value: number | User; } | null; updatedAt: string; createdAt: string; @@ -203,14 +203,14 @@ export interface User { * via the `definition` "media". */ export interface Media { - id: string; + id: number; createdBy?: { relationTo: 'users'; - value: string | User; + value: number | User; } | null; updatedBy?: { relationTo: 'users'; - value: string | User; + value: number | User; } | null; updatedAt: string; createdAt: string; @@ -223,7 +223,7 @@ export interface Media { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { thumbnail?: { url?: string | null; width?: number | null; @@ -255,7 +255,7 @@ export interface Media { * via the `definition` "payload-kv". */ export interface PayloadKv { - id: string; + id: number; key: string; data: | { @@ -272,24 +272,24 @@ export interface PayloadKv { * via the `definition` "payload-locked-documents". */ export interface PayloadLockedDocument { - id: string; + id: number; document?: | ({ relationTo: 'posts'; - value: string | Post; + value: number | Post; } | null) | ({ relationTo: 'media'; - value: string | Media; + value: number | Media; } | null) | ({ relationTo: 'users'; - value: string | User; + value: number | User; } | null); globalSlug?: string | null; user: { relationTo: 'users'; - value: string | User; + value: number | User; }; updatedAt: string; createdAt: string; @@ -299,10 +299,10 @@ export interface PayloadLockedDocument { * via the `definition` "payload-preferences". */ export interface PayloadPreference { - id: string; + id: number; user: { relationTo: 'users'; - value: string | User; + value: number | User; }; key?: string | null; value?: @@ -322,7 +322,7 @@ export interface PayloadPreference { * via the `definition` "payload-migrations". */ export interface PayloadMigration { - id: string; + id: number; name?: string | null; batch?: number | null; updatedAt: string; @@ -358,7 +358,7 @@ export interface MediaSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { thumbnail?: @@ -463,15 +463,15 @@ export interface PayloadMigrationsSelect { * via the `definition` "menu". */ export interface Menu { - id: string; + id: number; globalText?: string | null; createdBy?: { relationTo: 'users'; - value: string | User; + value: number | User; } | null; updatedBy?: { relationTo: 'users'; - value: string | User; + value: number | User; } | null; updatedAt?: string | null; createdAt?: string | null; diff --git a/test/a11y/payload-types.ts b/test/a11y/payload-types.ts index e7e21eef8c0..19fefa322df 100644 --- a/test/a11y/payload-types.ts +++ b/test/a11y/payload-types.ts @@ -378,7 +378,7 @@ export interface Media { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { thumbnail?: { url?: string | null; width?: number | null; @@ -634,7 +634,7 @@ export interface MediaSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { thumbnail?: diff --git a/test/admin-bar/payload-types.ts b/test/admin-bar/payload-types.ts index de9a71888c9..2192c3bdcd1 100644 --- a/test/admin-bar/payload-types.ts +++ b/test/admin-bar/payload-types.ts @@ -199,7 +199,7 @@ export interface Media { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { thumbnail?: { url?: string | null; width?: number | null; @@ -334,7 +334,7 @@ export interface MediaSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { thumbnail?: diff --git a/test/admin/payload-types.ts b/test/admin/payload-types.ts index cc2626f5210..dbedcd899fd 100644 --- a/test/admin/payload-types.ts +++ b/test/admin/payload-types.ts @@ -338,7 +338,7 @@ export interface Upload { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { thumbnail?: { url?: string | null; width?: number | null; @@ -1401,7 +1401,7 @@ export interface UploadsSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { thumbnail?: diff --git a/test/benchmark-blocks/payload-types.ts b/test/benchmark-blocks/payload-types.ts index 7b6d7a2d25e..8e3e9af67e6 100644 --- a/test/benchmark-blocks/payload-types.ts +++ b/test/benchmark-blocks/payload-types.ts @@ -35046,7 +35046,7 @@ export interface Media { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { thumbnail?: { url?: string | null; width?: number | null; @@ -35796,7 +35796,7 @@ export interface MediaSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { thumbnail?: diff --git a/test/email/payload-types.ts b/test/email/payload-types.ts index 093019eca3a..a79885d26da 100644 --- a/test/email/payload-types.ts +++ b/test/email/payload-types.ts @@ -170,7 +170,7 @@ export interface Media { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { thumbnail?: { url?: string | null; width?: number | null; @@ -340,7 +340,7 @@ export interface MediaSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { thumbnail?: diff --git a/test/group-by/payload-types.ts b/test/group-by/payload-types.ts index d3fd3607972..177759c8b65 100644 --- a/test/group-by/payload-types.ts +++ b/test/group-by/payload-types.ts @@ -253,7 +253,7 @@ export interface Media { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { thumbnail?: { url?: string | null; width?: number | null; @@ -540,7 +540,7 @@ export interface MediaSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { thumbnail?: diff --git a/test/lexical-mdx/payload-types.ts b/test/lexical-mdx/payload-types.ts index b27a1678ee7..5af380f1ea7 100644 --- a/test/lexical-mdx/payload-types.ts +++ b/test/lexical-mdx/payload-types.ts @@ -286,7 +286,7 @@ export interface Media { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { thumbnail?: { url?: string | null; width?: number | null; @@ -445,7 +445,7 @@ export interface MediaSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { thumbnail?: diff --git a/test/lexical/lexical.int.spec.ts b/test/lexical/lexical.int.spec.ts index 86e26579fb1..5b4873392d9 100644 --- a/test/lexical/lexical.int.spec.ts +++ b/test/lexical/lexical.int.spec.ts @@ -1308,7 +1308,7 @@ test.suite('Lexical', { config: './config.ts' }, () => { filename: 'test.pdf', height: 100, mimeType: 'application/pdf', - sizes: {}, + variants: {}, url: '/uploads/test.pdf', width: 100, }, @@ -1372,7 +1372,7 @@ test.suite('Lexical', { config: './config.ts' }, () => { filename: 'photo.jpg', height: 600, mimeType: 'image/jpeg', - sizes: {}, + variants: {}, url: '/uploads/photo.jpg', width: 800, }, @@ -1562,7 +1562,7 @@ test.suite('Lexical', { config: './config.ts' }, () => { filename: 'photo.jpg', height: 600, mimeType: 'image/jpeg', - sizes: { + variants: { thumbnail: { filename: 'photo-thumb.jpg', filesize: 1000, diff --git a/test/payload-cloud/payload-types.ts b/test/payload-cloud/payload-types.ts index ee3e38d0386..69e51644593 100644 --- a/test/payload-cloud/payload-types.ts +++ b/test/payload-cloud/payload-types.ts @@ -208,7 +208,7 @@ export interface Media { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { square?: { url?: string | null; width?: number | null; @@ -344,7 +344,7 @@ export interface MediaSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { square?: diff --git a/test/plugin-cloud-storage/int.spec.ts b/test/plugin-cloud-storage/int.spec.ts index 900c55a2b1b..e0b72e99099 100644 --- a/test/plugin-cloud-storage/int.spec.ts +++ b/test/plugin-cloud-storage/int.spec.ts @@ -54,9 +54,9 @@ async function verifyUploads({ id: uploadId, collection: collectionSlug, overrideAccess: true, - })) as unknown as { filename: string; sizes: Record } + })) as unknown as { filename: string; variants: Record } - const sizes = uploadData.sizes ?? {} + const sizes = uploadData.variants ?? {} const fileKeys = Object.keys(sizes).map((key) => { const entry = sizes[key] if (!entry) { @@ -174,7 +174,7 @@ test.suite('@payloadcms/plugin-cloud-storage', { config: './config.ts' }, () => expect(result).toBe('relative/path') }) - test('should normalize backslash separators', async () => { + test('should normalize backslash separators in prefixes', async () => { const result = await getFilePrefix({ collection: mockCollection, filename: 'test.png', @@ -242,7 +242,7 @@ test.suite('@payloadcms/plugin-cloud-storage', { config: './config.ts' }, () => expect(sanitizeFilename('a/b/../../c/d/../file.txt')).toBe('file.txt') }) - test('should normalize backslash separators', () => { + test('should normalize backslash separators in filenames', () => { expect(sanitizeFilename('..\\..\\windows\\system32\\config')).toBe('config') }) @@ -407,11 +407,11 @@ test.suite('@payloadcms/plugin-cloud-storage', { config: './config.ts' }, () => collection: mediaSlug, overrideAccess: true, }) - expect(apiResponse.sizes).toBeTruthy() + expect(apiResponse.variants).toBeTruthy() - const apiSizeKeys = Object.keys(apiResponse.sizes || {}) + const apiSizeKeys = Object.keys(apiResponse.variants || {}) for (const sizeKey of apiSizeKeys) { - const size = apiResponse.sizes?.[sizeKey as keyof typeof apiResponse.sizes] + const size = apiResponse.variants?.[sizeKey as keyof typeof apiResponse.variants] if (!size) { continue } @@ -427,16 +427,16 @@ test.suite('@payloadcms/plugin-cloud-storage', { config: './config.ts' }, () => const dbRecord = rawDbData as unknown as { filename: string - sizes: Record url: string + variants: Record } type SizeData = { filename: string; url: string } - const sizeKeys = Object.keys(dbRecord.sizes) + const sizeKeys = Object.keys(dbRecord.variants) expect(sizeKeys.length).toBeGreaterThan(0) for (const sizeKey of sizeKeys) { - const size: SizeData = dbRecord.sizes[sizeKey] as SizeData + const size: SizeData = dbRecord.variants[sizeKey] as SizeData expect(size.url).not.toEqual(`/api/${mediaSlug}/file/${dbRecord.filename}`) expect(size.url).toEqual(`/api/${mediaSlug}/file/${size.filename}`) } @@ -454,7 +454,7 @@ test.suite('@payloadcms/plugin-cloud-storage', { config: './config.ts' }, () => expect(upload.url).toEqual( `/api/${mediaWithPrefixSlug}/file/${upload.filename}?prefix=${prefix}`, ) - expect((upload as any).sizes).toBeFalsy() + expect((upload as any).variants).toBeFalsy() const rawDbData = await payload.db.findOne({ collection: mediaWithPrefixSlug, @@ -470,7 +470,7 @@ test.suite('@payloadcms/plugin-cloud-storage', { config: './config.ts' }, () => expect(dbRecord.filename).toEqual(upload.filename) expect(dbRecord.url).toEqual(`/api/${mediaWithPrefixSlug}/file/${upload.filename}`) - expect((rawDbData as any)?.sizes).toBeFalsy() + expect((rawDbData as any)?.variants).toBeFalsy() }) test('should use custom generateFileURL in beforeChange when disablePayloadAccessControl is true', async ({ @@ -594,14 +594,14 @@ test.suite('@payloadcms/plugin-cloud-storage', { config: './config.ts' }, () => const originalFilenames = [ upload.filename, - ...Object.values(upload.sizes || {}).map((size) => size?.filename), + ...Object.values(upload.variants || {}).map((size) => size?.filename), ].filter((value): value is string => typeof value === 'string') const updateResponse = await restClient.PATCH(`/${testMetadataSlug}/${upload.id}`, { body: JSON.stringify({ filename: 'submitted.png', prefix: 'submitted-prefix', - sizes: { + variants: { thumbnail: { filename: 'submitted-thumbnail.png', }, @@ -638,14 +638,14 @@ test.suite('@payloadcms/plugin-cloud-storage', { config: './config.ts' }, () => const originalFilenames = [ upload.filename, - ...Object.values(upload.sizes || {}).map((size) => size?.filename), + ...Object.values(upload.variants || {}).map((size) => size?.filename), ].filter((value): value is string => typeof value === 'string') const metadataUpdateResponse = await restClient.PATCH(`/${testMetadataSlug}/${upload.id}`, { body: JSON.stringify({ filename: 'submitted.png', prefix: 'submitted-prefix', - sizes: { + variants: { thumbnail: { filename: 'submitted-thumbnail.png', }, @@ -663,7 +663,7 @@ test.suite('@payloadcms/plugin-cloud-storage', { config: './config.ts' }, () => expect(preservedUpload.filename).toBe(upload.filename) expect(preservedUpload.prefix).toBe('test-metadata') - expect(preservedUpload.sizes).toEqual(upload.sizes) + expect(preservedUpload.variants).toEqual(upload.variants) recordedCleanupTargets.length = 0 @@ -693,7 +693,7 @@ test.suite('@payloadcms/plugin-cloud-storage', { config: './config.ts' }, () => }) const replacementFilenames = [ replacement.filename, - ...Object.values(replacement.sizes || {}).map((size) => size?.filename), + ...Object.values(replacement.variants || {}).map((size) => size?.filename), ].filter((value): value is string => typeof value === 'string') expect(replacement.filename).not.toBe('submitted.png') @@ -743,7 +743,7 @@ test.suite('@payloadcms/plugin-cloud-storage', { config: './config.ts' }, () => expect(saved.filename).toBeTruthy() expect(saved.filename).not.toBe(original.filename) expect(saved.mimeType).toBe('image/webp') - expect(saved.sizes?.thumbnail?.filename).toBeTruthy() + expect(saved.variants?.thumbnail?.filename).toBeTruthy() expect([...uploadedTestFiles.values()]).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -752,8 +752,8 @@ test.suite('@payloadcms/plugin-cloud-storage', { config: './config.ts' }, () => prefix: 'test-metadata', }), expect.objectContaining({ - filename: saved.sizes?.thumbnail?.filename, - mimeType: saved.sizes?.thumbnail?.mimeType, + filename: saved.variants?.thumbnail?.filename, + mimeType: saved.variants?.thumbnail?.mimeType, prefix: 'test-metadata', }), ]), @@ -878,8 +878,8 @@ test.suite('@payloadcms/plugin-cloud-storage', { config: './config.ts' }, () => const { doc } = await createResponse.json<{ doc: { id: number | string - sizes: { thumbnail: { filename: string } } storageProvider: string + variants: { thumbnail: { filename: string } } } }>() @@ -887,7 +887,7 @@ test.suite('@payloadcms/plugin-cloud-storage', { config: './config.ts' }, () => expect(createResponse.status).toBe(201) expect(doc.storageProvider).toBe('test-adapter') - expect(doc.sizes.thumbnail.filename).toBeTruthy() + expect(doc.variants.thumbnail.filename).toBeTruthy() }) test('should persist metadata on update operations', async ({ payload }) => { @@ -1051,12 +1051,12 @@ test.suite('@payloadcms/plugin-cloud-storage', { config: './config.ts' }, () => })) as unknown as { filename: string id: number | string - sizes: Record + variants: Record } createdIDs.push(initial.id) - const initialSizeKeys = Object.values(initial.sizes ?? {}) + const initialSizeKeys = Object.values(initial.variants ?? {}) .map((s) => s?.filename) .filter((f): f is string => typeof f === 'string') @@ -1078,13 +1078,13 @@ test.suite('@payloadcms/plugin-cloud-storage', { config: './config.ts' }, () => overwriteExistingFiles: true, })) as unknown as { filename: string - sizes: Record + variants: Record } // Filenames should match because overwriteExistingFiles is enabled. expect(updated.filename).toBe(initial.filename) - const updatedSizeKeys = Object.values(updated.sizes ?? {}) + const updatedSizeKeys = Object.values(updated.variants ?? {}) .map((s) => s?.filename) .filter((f): f is string => typeof f === 'string') @@ -1101,11 +1101,11 @@ test.suite('@payloadcms/plugin-cloud-storage', { config: './config.ts' }, () => }) test.describe('Azure', () => { - test.todo('can upload') + test.todo('can upload to Azure') }) test.describe('GCS', () => { - test.todo('can upload') + test.todo('can upload to GCS') }) test.describe('R2', () => { diff --git a/test/plugin-cloud-storage/payload-types.ts b/test/plugin-cloud-storage/payload-types.ts index 024c160506d..1bc987463d4 100644 --- a/test/plugin-cloud-storage/payload-types.ts +++ b/test/plugin-cloud-storage/payload-types.ts @@ -165,7 +165,7 @@ export interface Media { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { square?: { url?: string | null; width?: number | null; @@ -358,7 +358,7 @@ export interface MediaWithOverwrite { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { square?: { url?: string | null; width?: number | null; @@ -498,7 +498,7 @@ export interface TestMetadatum { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { thumbnail?: { url?: string | null; width?: number | null; @@ -640,7 +640,7 @@ export interface MediaSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { square?: @@ -770,7 +770,7 @@ export interface MediaWithOverwriteSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { square?: @@ -882,7 +882,7 @@ export interface TestMetadataSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { thumbnail?: diff --git a/test/sizes-to-variants/config.ts b/test/sizes-to-variants/config.ts new file mode 100644 index 00000000000..f3abf2aa97f --- /dev/null +++ b/test/sizes-to-variants/config.ts @@ -0,0 +1,46 @@ +import { sharpTransformer } from '@payloadcms/transformer-sharp' +import path from 'path' +import sharp from 'sharp' +import { fileURLToPath } from 'url' + +import { buildConfigWithDefaults } from '../buildConfigWithDefaults.js' +import { camelCaseVariantName, mediaSlug, variantName } from './shared.js' + +const filename = fileURLToPath(import.meta.url) +const dirname = path.dirname(filename) + +export default buildConfigWithDefaults({ + config: { + collections: [ + { + slug: 'users', + auth: true, + fields: [], + }, + { + slug: mediaSlug, + fields: [], + upload: { + staticDir: path.resolve(dirname, 'media'), + }, + versions: true, + }, + ], + upload: { + transformers: [ + sharpTransformer({ + collections: { + [mediaSlug]: { + variants: [ + { name: variantName, height: 40, width: 40 }, + { name: camelCaseVariantName, width: 60 }, + ], + }, + }, + sharp, + }), + ], + }, + }, + suite: 'sizes-to-variants', +}) diff --git a/test/sizes-to-variants/int.spec.ts b/test/sizes-to-variants/int.spec.ts new file mode 100644 index 00000000000..c56b55b7c22 --- /dev/null +++ b/test/sizes-to-variants/int.spec.ts @@ -0,0 +1,785 @@ +import type { Payload, PayloadRequest } from 'payload' + +import { migrateSizesToVariants as migrateMongoSizesToVariants } from '@payloadcms/db-mongodb/migration-utils' +import { migrateSizesToVariants as migratePostgresSizesToVariants } from '@payloadcms/db-postgres/migration-utils' +import { migrateSizesToVariants as migrateSqliteSizesToVariants } from '@payloadcms/db-sqlite/migration-utils' +import { sql } from 'drizzle-orm' +import fs from 'fs' +import path from 'path' +import { commitTransaction, initTransaction } from 'payload' +import { wait } from 'payload/shared' +import { fileURLToPath } from 'url' +import { expect } from 'vitest' + +import { test } from '../__helpers/int/vitest.js' +import { camelCaseVariantName, mediaSlug, variantName } from './shared.js' + +const filename = fileURLToPath(import.meta.url) +const dirname = path.dirname(filename) + +const variantFilenameColumn = `variants_${variantName}_filename` +const legacyFilenameColumn = `sizes_${variantName}_filename` +const quotedIdentifierCollectionSlug = 'quoted-identifiers' +const quotedIdentifierColumn = `variants_v_"'\`` +const quotedIdentifierIndex = `i_"'\`` +const quotedIdentifierTable = `m_"'\`` + +const getQuotedIdentifierMigrationPayload = ({ payload }: { payload: Payload }): Payload => { + const adapter = payload.db as any + + return { + db: { + payload: { + config: { + collections: [ + { + slug: quotedIdentifierCollectionSlug, + upload: { variants: [{ name: 'v' }] }, + }, + ], + }, + }, + rawTables: { + [quotedIdentifierTable]: { + name: quotedIdentifierTable, + columns: { + variants_v_value: { name: quotedIdentifierColumn }, + }, + indexes: { + variant: { + name: quotedIdentifierIndex, + on: 'variants_v_value', + }, + }, + }, + }, + schemaName: adapter.schemaName, + tableNameMap: new Map([['quoted_identifiers', quotedIdentifierTable]]), + versionsSuffix: adapter.versionsSuffix, + }, + logger: payload.logger, + } as Payload +} + +const createMedia = async ({ payload }: { payload: Payload }) => { + const doc = await payload.create({ + collection: mediaSlug, + data: {}, + filePath: path.resolve(dirname, '../uploads/image.png'), + overrideAccess: true, + }) + + const variantFilename = doc.variants?.[variantName]?.filename + + if (!variantFilename) { + throw new Error(`Expected the "${variantName}" variant to be generated`) + } + + return { id: doc.id, filename: doc.filename!, variantFilename } +} + +/** The index names the current schema expects on a table's variant filename columns. */ +const getExpectedIndexNames = ({ + payload, + tableName, +}: { + payload: Payload + tableName: string +}): string[] => { + const rawTable = (payload.db as any).rawTables[tableName] + + return [variantName, camelCaseVariantName].map((name) => { + // Raw index `on` holds the column's schema key, which keeps the camelCase variant name. + const columnKey = `variants_${name}_filename` + const index = Object.values<{ name: string; on: string | string[] }>( + rawTable.indexes ?? {}, + ).find((rawIndex) => [rawIndex.on].flat().includes(columnKey)) + + if (!index) { + throw new Error(`Expected an index on ${columnKey}`) + } + + return index.name + }) +} + +test.suite('sizes-to-variants migration', { config: './config.ts' }, () => { + test.afterEach(() => { + fs.rmSync(path.resolve(dirname, 'media'), { force: true, recursive: true }) + }) + + test('should not return the legacy sizes field', async ({ payload }) => { + const { id } = await createMedia({ payload }) + + const doc = await payload.findByID({ id, collection: mediaSlug, overrideAccess: true }) + + expect(doc).not.toHaveProperty('sizes') + }) + + test.options.describe( + 'PostgreSQL', + { db: (adapter) => adapter.startsWith('postgres') || adapter.startsWith('vercel-postgres') }, + () => { + const getColumns = async ({ + payload, + tableName, + }: { + payload: Payload + tableName: string + }) => { + const schemaName = (payload.db as any).schemaName || 'public' + const result = await (payload.db as any).drizzle.execute(sql` + SELECT column_name FROM information_schema.columns + WHERE table_schema = ${schemaName} AND table_name = ${tableName} + `) + + return result.rows.map((row: { column_name: string }) => row.column_name) as string[] + } + + const getIndexNames = async ({ + payload, + tableName, + }: { + payload: Payload + tableName: string + }) => { + const schemaName = (payload.db as any).schemaName || 'public' + const result = await (payload.db as any).drizzle.execute(sql` + SELECT indexname FROM pg_indexes WHERE schemaname = ${schemaName} AND tablename = ${tableName} + `) + + return result.rows.map((row: { indexname: string }) => row.indexname) as string[] + } + + const migrate = ({ direction, payload }: { direction: 'down' | 'up'; payload: Payload }) => + migratePostgresSizesToVariants({ db: (payload.db as any).drizzle, direction, payload }) + + test('should move legacy sizes columns to variants in Postgres without losing data', async ({ + payload, + }) => { + const { id, variantFilename } = await createMedia({ payload }) + + await migrate({ direction: 'down', payload }) + + expect(await getColumns({ payload, tableName: mediaSlug })).toContain(legacyFilenameColumn) + expect(await getColumns({ payload, tableName: `_${mediaSlug}_v` })).toContain( + `version_${legacyFilenameColumn}`, + ) + + await migrate({ direction: 'up', payload }) + + const columns = await getColumns({ payload, tableName: mediaSlug }) + + expect(columns).toContain(variantFilenameColumn) + expect(columns).not.toContain(legacyFilenameColumn) + expect(await getColumns({ payload, tableName: `_${mediaSlug}_v` })).toContain( + `version_${variantFilenameColumn}`, + ) + + const doc = await payload.findByID({ id, collection: mediaSlug, overrideAccess: true }) + + expect(doc.variants?.[variantName]?.filename).toBe(variantFilename) + }) + + test('should rename the variant indexes to the names the current schema expects', async ({ + payload, + }) => { + await createMedia({ payload }) + + const expectedIndexNames = getExpectedIndexNames({ payload, tableName: mediaSlug }) + + await migrate({ direction: 'down', payload }) + + expect(await getIndexNames({ payload, tableName: mediaSlug })).not.toEqual( + expect.arrayContaining(expectedIndexNames), + ) + + await migrate({ direction: 'up', payload }) + + expect(await getIndexNames({ payload, tableName: mediaSlug })).toEqual( + expect.arrayContaining(expectedIndexNames), + ) + }) + + test('should be safe to re-run once the columns are renamed', async ({ payload }) => { + const { id, variantFilename } = await createMedia({ payload }) + + await migrate({ direction: 'up', payload }) + + const doc = await payload.findByID({ id, collection: mediaSlug, overrideAccess: true }) + + expect(doc.variants?.[variantName]?.filename).toBe(variantFilename) + }) + + test('should reject a PostgreSQL source and destination column collision before changing the table', async ({ + payload, + }) => { + await createMedia({ payload }) + await migrate({ direction: 'down', payload }) + + const db = (payload.db as any).drizzle + const schemaName = (payload.db as any).schemaName || 'public' + + await db.execute(sql` + ALTER TABLE ${sql.identifier(schemaName)}.${sql.identifier(mediaSlug)} + ADD COLUMN ${sql.identifier(variantFilenameColumn)} varchar + `) + + try { + await expect(migrate({ direction: 'up', payload })).rejects.toThrow( + `contains both "${legacyFilenameColumn}" and "${variantFilenameColumn}"`, + ) + + const columns = await getColumns({ payload, tableName: mediaSlug }) + + expect(columns).toContain(legacyFilenameColumn) + expect(columns).toContain(variantFilenameColumn) + } finally { + await db.execute(sql` + ALTER TABLE ${sql.identifier(schemaName)}.${sql.identifier(mediaSlug)} + DROP COLUMN ${sql.identifier(variantFilenameColumn)} + `) + await migrate({ direction: 'up', payload }) + } + }) + + test('should reject disjoint custom variants columns in PostgreSQL before changing the table', async ({ + payload, + }) => { + await createMedia({ payload }) + await migrate({ direction: 'down', payload }) + + const customVariantsColumn = 'variants_custom_value' + const db = (payload.db as any).drizzle + const schemaName = (payload.db as any).schemaName || 'public' + + await db.execute(sql` + ALTER TABLE ${sql.identifier(schemaName)}.${sql.identifier(mediaSlug)} + ADD COLUMN ${sql.identifier(customVariantsColumn)} varchar + `) + + try { + await expect(migrate({ direction: 'up', payload })).rejects.toThrow( + 'contains both the "sizes" and "variants" fields', + ) + + expect(await getColumns({ payload, tableName: mediaSlug })).toContain( + legacyFilenameColumn, + ) + } finally { + await db.execute(sql` + ALTER TABLE ${sql.identifier(schemaName)}.${sql.identifier(mediaSlug)} + DROP COLUMN ${sql.identifier(customVariantsColumn)} + `) + await migrate({ direction: 'up', payload }) + } + }) + + test('should migrate quoted PostgreSQL identifiers', async ({ payload }) => { + const db = (payload.db as any).drizzle + const schemaName = (payload.db as any).schemaName || 'public' + const migrationPayload = getQuotedIdentifierMigrationPayload({ payload }) + + await db.execute(sql` + CREATE TABLE ${sql.identifier(schemaName)}.${sql.identifier(quotedIdentifierTable)} ( + ${sql.identifier(quotedIdentifierColumn)} varchar + ) + `) + await db.execute(sql` + CREATE INDEX ${sql.identifier(quotedIdentifierIndex)} + ON ${sql.identifier(schemaName)}.${sql.identifier(quotedIdentifierTable)} + (${sql.identifier(quotedIdentifierColumn)}) + `) + + try { + await migratePostgresSizesToVariants({ db, direction: 'down', payload: migrationPayload }) + await migratePostgresSizesToVariants({ db, payload: migrationPayload }) + + expect(await getColumns({ payload, tableName: quotedIdentifierTable })).toContain( + quotedIdentifierColumn, + ) + } finally { + await db.execute(sql` + DROP TABLE IF EXISTS ${sql.identifier(schemaName)}.${sql.identifier(quotedIdentifierTable)} + `) + } + }) + }, + ) + + test.options.describe('SQLite', { db: (adapter) => adapter.startsWith('sqlite') }, () => { + const getColumns = async ({ payload, tableName }: { payload: Payload; tableName: string }) => { + const rows: { name: string }[] = await (payload.db as any).drizzle.all( + sql.raw(`SELECT name FROM pragma_table_info('${tableName}')`), + ) + + return rows.map(({ name }) => name) + } + + const getIndexNames = async ({ + payload, + tableName, + }: { + payload: Payload + tableName: string + }) => { + const rows: { name: string }[] = await (payload.db as any).drizzle.all( + sql.raw(`SELECT name FROM pragma_index_list('${tableName}')`), + ) + + return rows.map(({ name }) => name) + } + + const migrate = ({ direction, payload }: { direction: 'down' | 'up'; payload: Payload }) => + migrateSqliteSizesToVariants({ db: (payload.db as any).drizzle, direction, payload }) + + test('should move legacy sizes columns to variants in SQLite without losing data', async ({ + payload, + }) => { + const { id, variantFilename } = await createMedia({ payload }) + + await migrate({ direction: 'down', payload }) + + expect(await getColumns({ payload, tableName: mediaSlug })).toContain(legacyFilenameColumn) + expect(await getColumns({ payload, tableName: `_${mediaSlug}_v` })).toContain( + `version_${legacyFilenameColumn}`, + ) + + await migrate({ direction: 'up', payload }) + + const columns = await getColumns({ payload, tableName: mediaSlug }) + + expect(columns).toContain(variantFilenameColumn) + expect(columns).not.toContain(legacyFilenameColumn) + + const doc = await payload.findByID({ id, collection: mediaSlug, overrideAccess: true }) + + expect(doc.variants?.[variantName]?.filename).toBe(variantFilename) + }) + + test('should recreate the variant indexes under the names the current schema expects', async ({ + payload, + }) => { + await createMedia({ payload }) + + const expectedIndexNames = getExpectedIndexNames({ payload, tableName: mediaSlug }) + + await migrate({ direction: 'down', payload }) + + expect(await getIndexNames({ payload, tableName: mediaSlug })).not.toEqual( + expect.arrayContaining(expectedIndexNames), + ) + + await migrate({ direction: 'up', payload }) + + expect(await getIndexNames({ payload, tableName: mediaSlug })).toEqual( + expect.arrayContaining(expectedIndexNames), + ) + }) + + test('should reject a SQLite source and destination column collision before changing the table', async ({ + payload, + }) => { + await createMedia({ payload }) + await migrate({ direction: 'down', payload }) + + const db = (payload.db as any).drizzle + + await db.run(sql` + ALTER TABLE ${sql.identifier(mediaSlug)} + ADD COLUMN ${sql.identifier(variantFilenameColumn)} text + `) + + try { + await expect(migrate({ direction: 'up', payload })).rejects.toThrow( + `contains both "${legacyFilenameColumn}" and "${variantFilenameColumn}"`, + ) + + const columns = await getColumns({ payload, tableName: mediaSlug }) + + expect(columns).toContain(legacyFilenameColumn) + expect(columns).toContain(variantFilenameColumn) + } finally { + await db.run(sql` + ALTER TABLE ${sql.identifier(mediaSlug)} + DROP COLUMN ${sql.identifier(variantFilenameColumn)} + `) + await migrate({ direction: 'up', payload }) + } + }) + + test('should restore a missing destination index when retrying after a partial failure', async ({ + payload, + }) => { + await createMedia({ payload }) + + const expectedIndexNames = getExpectedIndexNames({ payload, tableName: mediaSlug }) + + await migrate({ direction: 'down', payload }) + + const db = (payload.db as any).drizzle + const legacyIndexRows: { index_name: string }[] = await db.all(sql` + SELECT il.name AS index_name + FROM pragma_index_list(${mediaSlug}) AS il + JOIN pragma_index_info(il.name) AS ii + WHERE il.origin = 'c' AND ii.name = ${legacyFilenameColumn} + `) + const legacyIndexName = legacyIndexRows[0]?.index_name + + expect(legacyIndexName).toBeDefined() + + await db.run(sql` + ALTER TABLE ${sql.identifier(mediaSlug)} + RENAME COLUMN ${sql.identifier(legacyFilenameColumn)} + TO ${sql.identifier(variantFilenameColumn)} + `) + await db.run(sql`DROP INDEX ${sql.identifier(legacyIndexName)}`) + + await migrate({ direction: 'up', payload }) + + expect(await getIndexNames({ payload, tableName: mediaSlug })).toContain( + expectedIndexNames[0], + ) + }) + + test('should preserve a custom index on a destination column when re-running', async ({ + payload, + }) => { + await createMedia({ payload }) + await migrate({ direction: 'down', payload }) + await migrate({ direction: 'up', payload }) + + const customIndexName = 'custom_variant_filename_idx' + const db = (payload.db as any).drizzle + + await db.run(sql` + CREATE INDEX ${sql.identifier(customIndexName)} + ON ${sql.identifier(mediaSlug)} (${sql.identifier(variantFilenameColumn)}) + `) + + try { + await migrate({ direction: 'up', payload }) + + expect(await getIndexNames({ payload, tableName: mediaSlug })).toContain(customIndexName) + } finally { + await db.run(sql`DROP INDEX IF EXISTS ${sql.identifier(customIndexName)}`) + } + }) + + test('should preserve a custom index when repairing a missing destination index', async ({ + payload, + }) => { + await createMedia({ payload }) + + const [destinationIndexName] = getExpectedIndexNames({ payload, tableName: mediaSlug }) + const customIndexName = 'custom_variant_filename_repair_idx' + const db = (payload.db as any).drizzle + + await db.run(sql`DROP INDEX ${sql.identifier(destinationIndexName)}`) + await db.run(sql` + CREATE INDEX ${sql.identifier(customIndexName)} + ON ${sql.identifier(mediaSlug)} (${sql.identifier(variantFilenameColumn)}) + `) + + try { + await migrate({ direction: 'up', payload }) + + const indexNames = await getIndexNames({ payload, tableName: mediaSlug }) + + expect(indexNames).toContain(destinationIndexName) + expect(indexNames).toContain(customIndexName) + } finally { + await db.run(sql`DROP INDEX IF EXISTS ${sql.identifier(customIndexName)}`) + } + }) + + test('should reject disjoint custom variants columns in SQLite before changing the table', async ({ + payload, + }) => { + await createMedia({ payload }) + await migrate({ direction: 'down', payload }) + + const customVariantsColumn = 'variants_custom_value' + const db = (payload.db as any).drizzle + + await db.run(sql` + ALTER TABLE ${sql.identifier(mediaSlug)} + ADD COLUMN ${sql.identifier(customVariantsColumn)} text + `) + + try { + await expect(migrate({ direction: 'up', payload })).rejects.toThrow( + 'contains both the "sizes" and "variants" fields', + ) + + expect(await getColumns({ payload, tableName: mediaSlug })).toContain(legacyFilenameColumn) + } finally { + await db.run(sql` + ALTER TABLE ${sql.identifier(mediaSlug)} + DROP COLUMN ${sql.identifier(customVariantsColumn)} + `) + await migrate({ direction: 'up', payload }) + } + }) + + test('should migrate quoted SQLite identifiers', async ({ payload }) => { + const db = (payload.db as any).drizzle + const migrationPayload = getQuotedIdentifierMigrationPayload({ payload }) + + await db.run(sql` + CREATE TABLE ${sql.identifier(quotedIdentifierTable)} ( + ${sql.identifier(quotedIdentifierColumn)} text + ) + `) + await db.run(sql` + CREATE INDEX ${sql.identifier(quotedIdentifierIndex)} + ON ${sql.identifier(quotedIdentifierTable)} (${sql.identifier(quotedIdentifierColumn)}) + `) + + try { + await migrateSqliteSizesToVariants({ db, direction: 'down', payload: migrationPayload }) + await migrateSqliteSizesToVariants({ db, payload: migrationPayload }) + + const rows: { name: string }[] = await db.all( + sql`SELECT name FROM pragma_table_info(${quotedIdentifierTable})`, + ) + + expect(rows.map(({ name }) => name)).toContain(quotedIdentifierColumn) + } finally { + await db.run(sql`DROP TABLE IF EXISTS ${sql.identifier(quotedIdentifierTable)}`) + } + }) + + test('should skip indexes when the configured table does not exist', async ({ payload }) => { + const db = (payload.db as any).drizzle + const migrationPayload = getQuotedIdentifierMigrationPayload({ payload }) + + await expect( + migrateSqliteSizesToVariants({ db, payload: migrationPayload }), + ).resolves.toBeUndefined() + }) + }) + + test.options.describe('MongoDB', { db: 'mongo' }, () => { + test.beforeEach(async () => { + // Let Mongo finish the index builds the fixture reset triggers before indexes are re-keyed. + await wait(1000) + }) + + const getRawDocument = ({ filename, payload }: { filename: string; payload: Payload }) => + (payload.db as any).collections[mediaSlug].collection.findOne({ filename }) + + const getIndexKeys = async ({ payload }: { payload: Payload }) => { + const indexes: { key: Record }[] = await (payload.db as any).collections[ + mediaSlug + ].collection.indexes() + + return indexes.flatMap(({ key }) => Object.keys(key)) + } + + const getIndexes = ({ payload }: { payload: Payload }) => + (payload.db as any).collections[mediaSlug].collection.indexes() as Promise< + { + collation?: { locale?: string; strength?: number } + hidden?: boolean + key: Record + name: string + partialFilterExpression?: Record + unique?: boolean + weights?: Record + }[] + > + + test('should move a legacy sizes field to variants without losing data', async ({ + payload, + }) => { + const { id, filename, variantFilename } = await createMedia({ payload }) + + await migrateMongoSizesToVariants({ direction: 'down', payload }) + + const legacyDocument = await getRawDocument({ filename, payload }) + + expect(legacyDocument.sizes?.[variantName]?.filename).toBe(variantFilename) + expect(legacyDocument.variants).toBeUndefined() + + await migrateMongoSizesToVariants({ payload }) + + const migratedDocument = await getRawDocument({ filename, payload }) + + expect(migratedDocument.sizes).toBeUndefined() + expect(migratedDocument.variants?.[variantName]?.filename).toBe(variantFilename) + + const doc = await payload.findByID({ id, collection: mediaSlug, overrideAccess: true }) + + expect(doc.variants?.[variantName]?.filename).toBe(variantFilename) + }) + + test('should migrate inside the transaction payload migrate runs it in', async ({ + payload, + }) => { + const { id, variantFilename } = await createMedia({ payload }) + + await migrateMongoSizesToVariants({ direction: 'down', payload }) + + const req = { payload } as unknown as PayloadRequest + + await initTransaction(req) + await migrateMongoSizesToVariants({ payload, req }) + await commitTransaction(req) + + const doc = await payload.findByID({ id, collection: mediaSlug, overrideAccess: true }) + + expect(doc.variants?.[variantName]?.filename).toBe(variantFilename) + }) + + test('should re-key indexes on the legacy sizes field', async ({ payload }) => { + await createMedia({ payload }) + + await migrateMongoSizesToVariants({ direction: 'down', payload }) + + expect(await getIndexKeys({ payload })).toContain(`sizes.${variantName}.filename`) + + await migrateMongoSizesToVariants({ payload }) + + const keys = await getIndexKeys({ payload }) + + expect(keys).toContain(`variants.${variantName}.filename`) + expect(keys).not.toContain(`sizes.${variantName}.filename`) + }) + + test('should reject documents with both source and destination fields before changing indexes', async ({ + payload, + }) => { + await createMedia({ payload }) + + await migrateMongoSizesToVariants({ direction: 'down', payload }) + + const collection = (payload.db as any).collections[mediaSlug].collection + const legacyPath = `sizes.${variantName}.filename` + const legacyIndex = (await getIndexes({ payload })).find(({ key }) => legacyPath in key) + + await collection.insertOne({ + filename: 'custom-variants-only.jpg', + variants: { [variantName]: { credit: 'Custom credit' } }, + }) + + try { + await expect(migrateMongoSizesToVariants({ payload })).rejects.toThrow( + 'contains both "sizes" and "variants" data', + ) + + expect((await getIndexes({ payload })).map(({ name }) => name)).toContain(legacyIndex?.name) + } finally { + await collection.deleteOne({ filename: 'custom-variants-only.jpg' }) + await migrateMongoSizesToVariants({ payload }) + } + }) + + test('should re-key text indexes on the legacy sizes field', async ({ payload }) => { + await createMedia({ payload }) + await migrateMongoSizesToVariants({ direction: 'down', payload }) + + const collection = (payload.db as any).collections[mediaSlug].collection + const legacyPath = `sizes.${variantName}.filename` + const destinationPath = `variants.${variantName}.filename` + const sourceIndexName = 'legacy_variant_filename_text' + + await collection.createIndex( + { [legacyPath]: 'text' }, + { name: sourceIndexName, weights: { [legacyPath]: 7 } }, + ) + + try { + await migrateMongoSizesToVariants({ payload }) + + const indexes = await getIndexes({ payload }) + const destinationIndex = indexes.find(({ weights }) => destinationPath in (weights ?? {})) + + expect(destinationIndex?.weights).toEqual({ [destinationPath]: 7 }) + expect(indexes.map(({ name }) => name)).not.toContain(sourceIndexName) + } finally { + const indexes = await getIndexes({ payload }) + + for (const index of indexes) { + if (legacyPath in (index.weights ?? {}) || destinationPath in (index.weights ?? {})) { + await collection.dropIndex(index.name) + } + } + } + }) + + test('should rewrite partial filters and preserve index options', async ({ payload }) => { + await createMedia({ payload }) + await migrateMongoSizesToVariants({ direction: 'down', payload }) + + const collection = (payload.db as any).collections[mediaSlug].collection + const legacyPath = `sizes.${variantName}.filename` + const destinationPath = `variants.${variantName}.filename` + const legacyIndex = (await getIndexes({ payload })).find(({ key }) => legacyPath in key) + + expect(legacyIndex).toBeDefined() + + await collection.dropIndex(legacyIndex!.name) + await collection.createIndex( + { [legacyPath]: 1 }, + { + name: legacyIndex!.name, + collation: { locale: 'en', strength: 2 }, + hidden: true, + partialFilterExpression: { [legacyPath]: { $exists: true } }, + unique: true, + }, + ) + + try { + await migrateMongoSizesToVariants({ payload }) + + const destinationIndex = (await getIndexes({ payload })).find( + ({ key }) => destinationPath in key, + ) + + expect(destinationIndex).toMatchObject({ + collation: { locale: 'en', strength: 2 }, + hidden: true, + partialFilterExpression: { [destinationPath]: { $exists: true } }, + unique: true, + }) + } finally { + const indexes = await getIndexes({ payload }) + + for (const index of indexes) { + if (legacyPath in index.key || destinationPath in index.key) { + await collection.dropIndex(index.name) + } + } + + await collection.createIndex({ [destinationPath]: 1 }) + } + }) + + test('should reject an incompatible destination index before removing the source index', async ({ + payload, + }) => { + await createMedia({ payload }) + await migrateMongoSizesToVariants({ direction: 'down', payload }) + + const collection = (payload.db as any).collections[mediaSlug].collection + const legacyPath = `sizes.${variantName}.filename` + const destinationPath = `variants.${variantName}.filename` + const sourceIndex = (await getIndexes({ payload })).find(({ key }) => legacyPath in key) + + expect(sourceIndex).toBeDefined() + + await collection.createIndex({ incompatibleDestination: 1 }, { name: `${destinationPath}_1` }) + + try { + await expect(migrateMongoSizesToVariants({ payload })).rejects.toThrow() + + expect((await getIndexes({ payload })).map(({ name }) => name)).toContain(sourceIndex!.name) + } finally { + await collection.dropIndex(`${destinationPath}_1`) + await migrateMongoSizesToVariants({ payload }) + } + }) + }) +}) diff --git a/test/sizes-to-variants/payload-types.ts b/test/sizes-to-variants/payload-types.ts new file mode 100644 index 00000000000..0fc544dd6d5 --- /dev/null +++ b/test/sizes-to-variants/payload-types.ts @@ -0,0 +1,419 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * This file was automatically generated by Payload. + * DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config, + * and re-run `payload generate:types` to regenerate this file. + */ + +/** + * Supported timezones in IANA format. + * + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "supportedTimezones". + */ +export type SupportedTimezones = + | 'Pacific/Midway' + | 'Pacific/Niue' + | 'Pacific/Honolulu' + | 'Pacific/Rarotonga' + | 'America/Anchorage' + | 'Pacific/Gambier' + | 'America/Los_Angeles' + | 'America/Tijuana' + | 'America/Denver' + | 'America/Phoenix' + | 'America/Chicago' + | 'America/Guatemala' + | 'America/New_York' + | 'America/Bogota' + | 'America/Caracas' + | 'America/Santiago' + | 'America/Buenos_Aires' + | 'America/Sao_Paulo' + | 'Atlantic/South_Georgia' + | 'Atlantic/Azores' + | 'Atlantic/Cape_Verde' + | 'Europe/London' + | 'Europe/Berlin' + | 'Africa/Lagos' + | 'Europe/Athens' + | 'Africa/Cairo' + | 'Europe/Moscow' + | 'Asia/Riyadh' + | 'Asia/Dubai' + | 'Asia/Baku' + | 'Asia/Karachi' + | 'Asia/Tashkent' + | 'Asia/Calcutta' + | 'Asia/Dhaka' + | 'Asia/Almaty' + | 'Asia/Jakarta' + | 'Asia/Bangkok' + | 'Asia/Shanghai' + | 'Asia/Singapore' + | 'Asia/Tokyo' + | 'Asia/Seoul' + | 'Australia/Brisbane' + | 'Australia/Sydney' + | 'Pacific/Guam' + | 'Pacific/Noumea' + | 'Pacific/Auckland' + | 'Pacific/Fiji'; + +export interface Config { + auth: { + users: UserAuthOperations; + }; + blocks: {}; + collections: { + users: User; + media: Media; + 'payload-kv': PayloadKv; + 'payload-locked-documents': PayloadLockedDocument; + 'payload-preferences': PayloadPreference; + 'payload-migrations': PayloadMigration; + }; + collectionsJoins: {}; + collectionsSelect: { + users: UsersSelect | UsersSelect; + media: MediaSelect | MediaSelect; + 'payload-kv': PayloadKvSelect | PayloadKvSelect; + 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; + 'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect; + 'payload-migrations': PayloadMigrationsSelect | PayloadMigrationsSelect; + }; + db: { + defaultIDType: string; + }; + fallbackLocale: null; + globals: {}; + globalsSelect: {}; + locale: null; + widgets: { + collections: CollectionsWidget; + 'collection-query': CollectionQueryWidget; + activity: ActivityWidget; + }; + user: User; + jobs: { + tasks: unknown; + workflows: unknown; + }; +} +export interface UserAuthOperations { + forgotPassword: { + email: string; + password: string; + }; + login: { + email: string; + password: string; + }; + registerFirstUser: { + email: string; + password: string; + }; + unlock: { + email: string; + password: string; + }; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "users". + */ +export interface User { + id: string; + createdBy?: { + relationTo: 'users'; + value: string | User; + } | null; + updatedBy?: { + relationTo: 'users'; + value: string | User; + } | null; + updatedAt: string; + createdAt: string; + email: string; + resetPasswordToken?: string | null; + resetPasswordExpiration?: string | null; + salt?: string | null; + hash?: string | null; + resetPasswordRequestedAt?: string | null; + loginAttempts?: number | null; + lockUntil?: string | null; + sessions?: + | { + id: string; + createdAt?: string | null; + expiresAt: string; + }[] + | null; + password?: string | null; + collection: 'users'; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "media". + */ +export interface Media { + id: string; + createdBy?: { + relationTo: 'users'; + value: string | User; + } | null; + updatedBy?: { + relationTo: 'users'; + value: string | User; + } | null; + updatedAt: string; + createdAt: string; + url?: string | null; + thumbnailURL?: string | null; + filename?: string | null; + mimeType?: string | null; + filesize?: number | null; + width?: number | null; + height?: number | null; + focalX?: number | null; + focalY?: number | null; + variants?: { + thumbnail?: { + url?: string | null; + width?: number | null; + height?: number | null; + mimeType?: string | null; + filesize?: number | null; + filename?: string | null; + }; + }; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-kv". + */ +export interface PayloadKv { + id: string; + key: string; + data: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-locked-documents". + */ +export interface PayloadLockedDocument { + id: string; + document?: + | ({ + relationTo: 'users'; + value: string | User; + } | null) + | ({ + relationTo: 'media'; + value: string | Media; + } | null); + globalSlug?: string | null; + user: { + relationTo: 'users'; + value: string | User; + }; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-preferences". + */ +export interface PayloadPreference { + id: string; + user: { + relationTo: 'users'; + value: string | User; + }; + key?: string | null; + value?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-migrations". + */ +export interface PayloadMigration { + id: string; + name?: string | null; + batch?: number | null; + updatedAt: string; + createdAt: string; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "users_select". + */ +export interface UsersSelect { + createdBy?: T; + updatedBy?: T; + updatedAt?: T; + createdAt?: T; + email?: T; + resetPasswordToken?: T; + resetPasswordExpiration?: T; + salt?: T; + hash?: T; + resetPasswordRequestedAt?: T; + loginAttempts?: T; + lockUntil?: T; + sessions?: + | T + | { + id?: T; + createdAt?: T; + expiresAt?: T; + }; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "media_select". + */ +export interface MediaSelect { + createdBy?: T; + updatedBy?: T; + updatedAt?: T; + createdAt?: T; + url?: T; + thumbnailURL?: T; + filename?: T; + mimeType?: T; + filesize?: T; + width?: T; + height?: T; + focalX?: T; + focalY?: T; + variants?: + | T + | { + thumbnail?: + | T + | { + url?: T; + width?: T; + height?: T; + mimeType?: T; + filesize?: T; + filename?: T; + }; + }; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-kv_select". + */ +export interface PayloadKvSelect { + key?: T; + data?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-locked-documents_select". + */ +export interface PayloadLockedDocumentsSelect { + document?: T; + globalSlug?: T; + user?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-preferences_select". + */ +export interface PayloadPreferencesSelect { + user?: T; + key?: T; + value?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "payload-migrations_select". + */ +export interface PayloadMigrationsSelect { + name?: T; + batch?: T; + updatedAt?: T; + createdAt?: T; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "collections_widget". + */ +export interface CollectionsWidget { + data?: { + [k: string]: unknown; + }; + width: 'full'; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "collection-query_widget". + */ +export interface CollectionQueryWidget { + data?: { + title?: string | null; + relatedCollection: 'users' | 'media'; + where?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + sortField?: string | null; + sortDirection?: ('asc' | 'desc') | null; + limit?: number | null; + }; + width: 'x-small' | 'small' | 'medium' | 'large' | 'x-large' | 'full'; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "activity_widget". + */ +export interface ActivityWidget { + data?: { + excludedCollections?: ('users' | 'media')[] | null; + }; + width: 'x-small' | 'small' | 'medium' | 'large' | 'x-large' | 'full'; +} +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "auth". + */ +export interface Auth { + [k: string]: unknown; +} + + +declare module 'payload' { + // @ts-ignore + export interface GeneratedTypes extends Config {} +} diff --git a/test/sizes-to-variants/shared.ts b/test/sizes-to-variants/shared.ts new file mode 100644 index 00000000000..5fe6008eb61 --- /dev/null +++ b/test/sizes-to-variants/shared.ts @@ -0,0 +1,4 @@ +export const mediaSlug = 'media' +export const variantName = 'thumbnail' +/** Covers index names built from a camelCase variant (`variants_heroLarge_filename` → `variants_hero_large_filename`). */ +export const camelCaseVariantName = 'heroLarge' diff --git a/test/sizes-to-variants/tsconfig.json b/test/sizes-to-variants/tsconfig.json new file mode 100644 index 00000000000..b29da4479f9 --- /dev/null +++ b/test/sizes-to-variants/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.json", + "include": ["./**/*.ts", "./**/*.tsx"] +} diff --git a/test/storage-azure/client-uploads/int.spec.ts b/test/storage-azure/client-uploads/int.spec.ts index 047dd1733a6..5ff2bedbe70 100644 --- a/test/storage-azure/client-uploads/int.spec.ts +++ b/test/storage-azure/client-uploads/int.spec.ts @@ -135,7 +135,7 @@ test.suite('@payloadcms/storage-azure clientUploads', { config: './config.ts' }, filename: 'legacy-original.png', mimeType: 'image/png', prefix, - sizes: { thumbnail: { filename: sizeFilename, mimeType: 'image/png' } }, + variants: { thumbnail: { filename: sizeFilename, mimeType: 'image/png' } }, }, }) const collection = payload.collections[mediaHeaderOnlyWithSizesSlug].config @@ -546,9 +546,9 @@ test.suite('@payloadcms/storage-azure clientUploads', { config: './config.ts' }, expect(doc.filesize).toBe(file.length) expect(doc.mimeType).toBe('image/jpeg') - expect(doc.sizes.thumbnail.width).toBe(400) - expect(doc.sizes.thumbnail.height).toBe(300) - expect(doc.sizes.thumbnail.filename).toBeTruthy() + expect(doc.variants.thumbnail.width).toBe(400) + expect(doc.variants.thumbnail.height).toBe(300) + expect(doc.variants.thumbnail.filename).toBeTruthy() expect(downloadSpy).toHaveBeenCalledTimes(1) diff --git a/test/storage-azure/client-uploads/payload-types.ts b/test/storage-azure/client-uploads/payload-types.ts index e69aa2bc7ec..b48a225a98b 100644 --- a/test/storage-azure/client-uploads/payload-types.ts +++ b/test/storage-azure/client-uploads/payload-types.ts @@ -146,7 +146,7 @@ export interface Media { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { square?: { url?: string | null; width?: number | null; @@ -239,7 +239,7 @@ export interface MediaHeaderOnlyWithSize { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { thumbnail?: { url?: string | null; width?: number | null; @@ -383,7 +383,7 @@ export interface MediaSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { square?: @@ -478,7 +478,7 @@ export interface MediaHeaderOnlyWithSizesSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { thumbnail?: diff --git a/test/storage-azure/int.spec.ts b/test/storage-azure/int.spec.ts index 133b5b85f8f..acc9a06b8b5 100644 --- a/test/storage-azure/int.spec.ts +++ b/test/storage-azure/int.spec.ts @@ -131,9 +131,9 @@ test.suite('@payloadcms/storage-azure', { config: './config.ts', resetBetweenTes collection: collectionSlug, id: uploadId, overrideAccess: true, - })) as unknown as { filename: string; sizes: Record } + })) as unknown as { filename: string; variants: Record } - const fileKeys = Object.values(uploadData.sizes || {}).map(({ filename: rawFilename }) => + const fileKeys = Object.values(uploadData.variants || {}).map(({ filename: rawFilename }) => prefix ? `${prefix}/${rawFilename}` : rawFilename, ) diff --git a/test/storage-azure/payload-types.ts b/test/storage-azure/payload-types.ts index 14e9f53c3a6..cb4fb99fd69 100644 --- a/test/storage-azure/payload-types.ts +++ b/test/storage-azure/payload-types.ts @@ -151,7 +151,7 @@ export interface Media { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { square?: { url?: string | null; width?: number | null; @@ -363,7 +363,7 @@ export interface MediaSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { square?: diff --git a/test/storage-azure/streamingUploads.int.spec.ts b/test/storage-azure/streamingUploads.int.spec.ts index 3e3876d6360..53663d71a47 100644 --- a/test/storage-azure/streamingUploads.int.spec.ts +++ b/test/storage-azure/streamingUploads.int.spec.ts @@ -109,10 +109,10 @@ test.suite('@payloadcms/storage-azure streamingUploads', { config: './config.ts' collection: collectionSlug, id: uploadId, overrideAccess: true, - })) as unknown as { filename: string; sizes: Record } + })) as unknown as { filename: string; variants: Record } - const fileKeys = Object.keys(uploadData.sizes || {}).map((key) => { - const rawFilename = uploadData.sizes[key].filename + const fileKeys = Object.keys(uploadData.variants || {}).map((key) => { + const rawFilename = uploadData.variants[key].filename return prefix ? `${prefix}/${rawFilename}` : rawFilename }) diff --git a/test/storage-gcs/int.spec.ts b/test/storage-gcs/int.spec.ts index 880b81efb13..8daed38ed66 100644 --- a/test/storage-gcs/int.spec.ts +++ b/test/storage-gcs/int.spec.ts @@ -60,9 +60,9 @@ test.suite('@payloadcms/storage-gcs', { config: './config.ts', resetBetweenTests id: uploadId, collection: collectionSlug as 'media', overrideAccess: true, - })) as unknown as { filename: string; sizes: Record } + })) as unknown as { filename: string; variants: Record } - const fileKeys = Object.values(uploadData.sizes || {}).map(({ filename: rawFilename }) => + const fileKeys = Object.values(uploadData.variants || {}).map(({ filename: rawFilename }) => filePrefix ? `${filePrefix}/${rawFilename}` : rawFilename, ) diff --git a/test/storage-gcs/payload-types.ts b/test/storage-gcs/payload-types.ts index 14e9f53c3a6..cb4fb99fd69 100644 --- a/test/storage-gcs/payload-types.ts +++ b/test/storage-gcs/payload-types.ts @@ -151,7 +151,7 @@ export interface Media { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { square?: { url?: string | null; width?: number | null; @@ -363,7 +363,7 @@ export interface MediaSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { square?: diff --git a/test/storage-r2/payload-types.ts b/test/storage-r2/payload-types.ts index 915d03ccc56..36dd1529dba 100644 --- a/test/storage-r2/payload-types.ts +++ b/test/storage-r2/payload-types.ts @@ -151,7 +151,7 @@ export interface Media { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { square?: { url?: string | null; width?: number | null; @@ -260,7 +260,7 @@ export interface MediaClient { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { square?: { url?: string | null; width?: number | null; @@ -382,7 +382,7 @@ export interface MediaSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { square?: @@ -449,7 +449,7 @@ export interface MediaClientSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { square?: diff --git a/test/storage-s3/client-uploads/int.spec.ts b/test/storage-s3/client-uploads/int.spec.ts index e55c4b21e7e..3ef3d3b2bc4 100644 --- a/test/storage-s3/client-uploads/int.spec.ts +++ b/test/storage-s3/client-uploads/int.spec.ts @@ -539,9 +539,9 @@ test.suite('@payloadcms/storage-s3 clientUploads', { config: './config.ts' }, () expect(doc.filesize).toBe(file.length) expect(doc.mimeType).toBe('image/jpeg') - expect(doc.sizes.thumbnail.width).toBe(400) - expect(doc.sizes.thumbnail.height).toBe(300) - expect(doc.sizes.thumbnail.filename).toBeTruthy() + expect(doc.variants.thumbnail.width).toBe(400) + expect(doc.variants.thumbnail.height).toBe(300) + expect(doc.variants.thumbnail.filename).toBeTruthy() }, 60000) }) diff --git a/test/storage-s3/client-uploads/payload-types.ts b/test/storage-s3/client-uploads/payload-types.ts index a6d75b613eb..c7683fdbfe7 100644 --- a/test/storage-s3/client-uploads/payload-types.ts +++ b/test/storage-s3/client-uploads/payload-types.ts @@ -146,7 +146,7 @@ export interface Media { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { square?: { url?: string | null; width?: number | null; @@ -229,7 +229,7 @@ export interface MediaHeaderOnlyWithSize { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { thumbnail?: { url?: string | null; width?: number | null; @@ -373,7 +373,7 @@ export interface MediaSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { square?: @@ -458,7 +458,7 @@ export interface MediaHeaderOnlyWithSizesSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { thumbnail?: diff --git a/test/storage-s3/int.spec.ts b/test/storage-s3/int.spec.ts index c4c3939ee71..77ac44ddcf6 100644 --- a/test/storage-s3/int.spec.ts +++ b/test/storage-s3/int.spec.ts @@ -224,8 +224,8 @@ test.suite('@payloadcms/storage-s3', { config: './config.ts' }, () => { expect(upload.id).toBeTruthy() // Verify image sizes URLs are returned correctly - expect(upload.sizes?.thumbnail?.url).toContain(process.env.S3_ENDPOINT) - expect(upload.sizes?.thumbnail?.url).toContain(getTestBucketName()) + expect(upload.variants?.thumbnail?.url).toContain(process.env.S3_ENDPOINT) + expect(upload.variants?.thumbnail?.url).toContain(getTestBucketName()) // CRITICAL: Verify that image size URLs are also stored as full S3 URLs in the database const dbDoc = await payload.db.findOne({ @@ -238,9 +238,9 @@ test.suite('@payloadcms/storage-s3', { config: './config.ts' }, () => { }) expect(dbDoc).toBeDefined() - expect(dbDoc.sizes.thumbnail.url).toContain(process.env.S3_ENDPOINT) - expect(dbDoc.sizes.thumbnail.url).toContain(getTestBucketName()) - expect(dbDoc.sizes.thumbnail.url).not.toMatch(/^\/api\//) + expect(dbDoc.variants.thumbnail.url).toContain(process.env.S3_ENDPOINT) + expect(dbDoc.variants.thumbnail.url).toContain(getTestBucketName()) + expect(dbDoc.variants.thumbnail.url).not.toMatch(/^\/api\//) await payload.delete({ collection: mediaWithDirectAccessSlug, diff --git a/test/storage-s3/payload-types.ts b/test/storage-s3/payload-types.ts index e2d9604660e..c85ff1422f1 100644 --- a/test/storage-s3/payload-types.ts +++ b/test/storage-s3/payload-types.ts @@ -157,7 +157,7 @@ export interface Media { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { square?: { url?: string | null; width?: number | null; @@ -267,7 +267,7 @@ export interface MediaWithDirectAccess { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { thumbnail?: { url?: string | null; width?: number | null; @@ -478,7 +478,7 @@ export interface MediaSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { square?: @@ -546,7 +546,7 @@ export interface MediaWithDirectAccessSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { thumbnail?: diff --git a/test/storage-s3/test-utils.ts b/test/storage-s3/test-utils.ts index 5622ab264f3..36836b09b8f 100644 --- a/test/storage-s3/test-utils.ts +++ b/test/storage-s3/test-utils.ts @@ -69,10 +69,10 @@ export async function verifyUploads({ collection: collectionSlug as CollectionSlug, id: uploadId, overrideAccess: true, - })) as unknown as { filename: string; sizes: Record } + })) as unknown as { filename: string; variants: Record } - const fileKeys = Object.keys(uploadData.sizes || {}).map((key) => { - const rawFilename = uploadData?.sizes?.[key]?.filename + const fileKeys = Object.keys(uploadData.variants || {}).map((key) => { + const rawFilename = uploadData?.variants?.[key]?.filename return prefix ? `${prefix}/${rawFilename}` : rawFilename }) diff --git a/test/storage-vercel-blob/client-uploads/payload-types.ts b/test/storage-vercel-blob/client-uploads/payload-types.ts index cdcdaf0eba2..e73bacb0598 100644 --- a/test/storage-vercel-blob/client-uploads/payload-types.ts +++ b/test/storage-vercel-blob/client-uploads/payload-types.ts @@ -142,7 +142,7 @@ export interface Media { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { square?: { url?: string | null; width?: number | null; @@ -315,7 +315,7 @@ export interface MediaSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { square?: diff --git a/test/storage-vercel-blob/int.spec.ts b/test/storage-vercel-blob/int.spec.ts index 660921c12f7..b25e39dba6a 100644 --- a/test/storage-vercel-blob/int.spec.ts +++ b/test/storage-vercel-blob/int.spec.ts @@ -166,16 +166,16 @@ test.suite('@payloadcms/storage-vercel-blob', { config: './config.ts' }, () => { overrideAccess: true, }) - expect(upload.sizes?.thumbnail?.url).toContain(process.env.STORAGE_VERCEL_BLOB_BASE_URL) - expect(upload.sizes?.thumbnail?.url).not.toMatch(/^\/api\//) + expect(upload.variants?.thumbnail?.url).toContain(process.env.STORAGE_VERCEL_BLOB_BASE_URL) + expect(upload.variants?.thumbnail?.url).not.toMatch(/^\/api\//) const dbDoc = await payload.db.findOne({ collection: mediaWithDirectAccessSlug, where: { id: { equals: upload.id } }, }) - expect(dbDoc?.sizes?.thumbnail?.url).toContain(process.env.STORAGE_VERCEL_BLOB_BASE_URL) - expect(dbDoc?.sizes?.thumbnail?.url).not.toMatch(/^\/api\//) + expect(dbDoc?.variants?.thumbnail?.url).toContain(process.env.STORAGE_VERCEL_BLOB_BASE_URL) + expect(dbDoc?.variants?.thumbnail?.url).not.toMatch(/^\/api\//) }) test('should return direct blob URL with encoded filename for file with spaces', async ({ diff --git a/test/storage-vercel-blob/payload-types.ts b/test/storage-vercel-blob/payload-types.ts index cbb340fa4d4..3192d7712ef 100644 --- a/test/storage-vercel-blob/payload-types.ts +++ b/test/storage-vercel-blob/payload-types.ts @@ -155,7 +155,7 @@ export interface Media { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { square?: { url?: string | null; width?: number | null; @@ -265,7 +265,7 @@ export interface MediaWithDirectAccess { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { thumbnail?: { url?: string | null; width?: number | null; @@ -444,7 +444,7 @@ export interface MediaSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { square?: @@ -512,7 +512,7 @@ export interface MediaWithDirectAccessSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { thumbnail?: diff --git a/test/storage-vercel-blob/test-utils.ts b/test/storage-vercel-blob/test-utils.ts index cdad6ba0345..0e3af333ef9 100644 --- a/test/storage-vercel-blob/test-utils.ts +++ b/test/storage-vercel-blob/test-utils.ts @@ -26,11 +26,11 @@ export async function verifyUploads({ collection: collectionSlug as CollectionSlug, id: uploadId, overrideAccess: true, - })) as unknown as { filename: string; sizes: Record } + })) as unknown as { filename: string; variants: Record } const { blobs } = await list() - const filenames = Object.values(uploadData.sizes || {}).map((s) => s.filename) + const filenames = Object.values(uploadData.variants || {}).map((s) => s.filename) filenames.push(uploadData.filename) for (const fn of filenames) { diff --git a/test/uploads/e2e.spec.ts b/test/uploads/e2e.spec.ts index 83ea40f20c6..f76bc8da907 100644 --- a/test/uploads/e2e.spec.ts +++ b/test/uploads/e2e.spec.ts @@ -814,7 +814,7 @@ describe('Uploads', () => { slug: animatedTypeMedia, auth: true, }) - expect(doc.sizes.squareSmall.filename).toMatch(/480x480\.webp$/) + expect(doc.variants.squareSmall.filename).toMatch(/480x480\.webp$/) }) test('should show resized images', async () => { @@ -834,20 +834,20 @@ describe('Uploads', () => { // The per-size detail drawer was replaced by the inline carousel, which no longer surfaces // per-size dimensions/formats; verify the generated sizes via the API instead. - const { sizes } = pngDoc! + const { variants } = pngDoc! - expect(sizes!.maintainedAspectRatio).toMatchObject({ height: 1024, width: 1024 }) - expect(sizes!.differentFormatFromMainImage!.mimeType).toBe('image/jpeg') - expect(sizes!.maintainedImageSize).toMatchObject({ height: 1600, width: 1600 }) - expect(sizes!.maintainedImageSizeWithNewFormat).toMatchObject({ + expect(variants!.maintainedAspectRatio).toMatchObject({ height: 1024, width: 1024 }) + expect(variants!.differentFormatFromMainImage!.mimeType).toBe('image/jpeg') + expect(variants!.maintainedImageSize).toMatchObject({ height: 1600, width: 1600 }) + expect(variants!.maintainedImageSizeWithNewFormat).toMatchObject({ height: 1600, mimeType: 'image/jpeg', width: 1600, }) - expect(sizes!.accidentalSameSize).toMatchObject({ height: 80, width: 320 }) - expect(sizes!.tablet).toMatchObject({ height: 480, width: 640 }) - expect(sizes!.mobile).toMatchObject({ height: 240, width: 320 }) - expect(sizes!.icon).toMatchObject({ height: 16, width: 16 }) + expect(variants!.accidentalSameSize).toMatchObject({ height: 80, width: 320 }) + expect(variants!.tablet).toMatchObject({ height: 480, width: 640 }) + expect(variants!.mobile).toMatchObject({ height: 240, width: 320 }) + expect(variants!.icon).toMatchObject({ height: 16, width: 16 }) }) test('should resize and show tiff images', async () => { @@ -898,7 +898,7 @@ describe('Uploads', () => { slug: customFileNameMediaSlug, auth: true, }) - expect(doc.sizes.custom.filename).toBe('custom-500x500.png') + expect(doc.variants.custom.filename).toBe('custom-500x500.png') }) test('should show draft uploads in the relation list', async () => { @@ -1239,7 +1239,7 @@ describe('Uploads', () => { const acceptableFileSizes = [2989] await expect - .poll(() => acceptableFileSizes.includes(mediaDoc.sizes.sizeOne.filesize)) + .poll(() => acceptableFileSizes.includes(mediaDoc.variants.sizeOne.filesize)) .toBe(true) }) @@ -1260,7 +1260,7 @@ describe('Uploads', () => { const acceptableFileSizes = [2424, 2445] await expect - .poll(() => acceptableFileSizes.includes(mediaDoc.sizes.sizeTwo.filesize)) + .poll(() => acceptableFileSizes.includes(mediaDoc.variants.sizeTwo.filesize)) .toBe(true) }) @@ -1282,7 +1282,7 @@ describe('Uploads', () => { // without metadata appended, the jpeg image filesize would be 2424 await expect - .poll(() => acceptableFileSizesForJPEG.includes(jpegMediaDoc.sizes.sizeThree.filesize)) + .poll(() => acceptableFileSizesForJPEG.includes(jpegMediaDoc.variants.sizeThree.filesize)) .toBe(true) await gotoAndWaitForForm(page, withOnlyJPEGMetadataURL.create) @@ -1299,7 +1299,7 @@ describe('Uploads', () => { }) // With metadata, the animated image filesize would be 218762 - await expect.poll(() => webpMediaDoc.sizes.sizeThree.filesize).toBe(211638) + await expect.poll(() => webpMediaDoc.variants.sizeThree.filesize).toBe(211638) }) test('should show custom upload component', async () => { @@ -2425,7 +2425,7 @@ describe('Uploads', () => { }) // without focal point update this generated size was equal to 1736 - await expect.poll(() => redDoc.sizes.focalTest.filesize).toBe(1586) + await expect.poll(() => redDoc.variants.focalTest.filesize).toBe(1586) }) test('should resize image after crop if resizeOptions defined', async () => { diff --git a/test/uploads/int.spec.ts b/test/uploads/int.spec.ts index 7f63765bd85..abdc5b316e4 100644 --- a/test/uploads/int.spec.ts +++ b/test/uploads/int.spec.ts @@ -305,10 +305,10 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: body: JSON.stringify({ filename: restrictedDoc.filename, prefix: 'restricted', - sizes: { + variants: { thumbnail: { - ...readableDoc.sizes.thumbnail, - filename: restrictedDoc.sizes.thumbnail.filename, + ...readableDoc.variants.thumbnail, + filename: restrictedDoc.variants.thumbnail.filename, }, }, url: restrictedDoc.url, @@ -326,7 +326,7 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: expect(updatedDoc.filename).toBe(readableDoc.filename) expect(updatedDoc.prefix).toBe('public') - expect(updatedDoc.sizes.thumbnail.filename).toBe(readableDoc.sizes.thumbnail.filename) + expect(updatedDoc.variants.thumbnail.filename).toBe(readableDoc.variants.thumbnail.filename) expect(updatedDoc.url).toBe(readableDoc.url) const fileResponse = await restClient.GET( @@ -355,7 +355,7 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: const sizeFilenameResponse = await restClient.PATCH(`/${fileAccessMediaSlug}/${doc.id}`, { body: JSON.stringify({ - sizes: { thumbnail: { filename: '..\\invalid-thumbnail.png' } }, + variants: { thumbnail: { filename: '..\\invalid-thumbnail.png' } }, }), }) expect(sizeFilenameResponse.status).toBe(400) @@ -388,7 +388,7 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: expect(updatedDoc.focalX).toBe(75) expect(updatedDoc.focalY).toBe(25) expect(updatedDoc.prefix).toBe('public') - expect(updatedDoc.sizes.thumbnail.filename).toBe(doc.sizes.thumbnail.filename) + expect(updatedDoc.variants.thumbnail.filename).toBe(doc.variants.thumbnail.filename) expect(updatedDoc.url).toBe(doc.url) }) @@ -416,7 +416,7 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: body: JSON.stringify({ filename: 'submitted.png', prefix: 'submitted', - sizes: { thumbnail: { filename: 'submitted-thumbnail.png' } }, + variants: { thumbnail: { filename: 'submitted-thumbnail.png' } }, url: '/api/file-access-media/file/submitted.png', }), query: { where: { id: { in: docs.map(({ id }) => id) } } }, @@ -433,7 +433,7 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: expect(updatedDoc.filename).toBe(doc.filename) expect(updatedDoc.prefix).toBe(doc.prefix) expect(updatedDoc.requestMetadata).toContain('PATCH:application/json:') - expect(updatedDoc.sizes.thumbnail.filename).toBe(doc.sizes.thumbnail.filename) + expect(updatedDoc.variants.thumbnail.filename).toBe(doc.variants.thumbnail.filename) expect(updatedDoc.url).toBe(doc.url) } }) @@ -459,8 +459,8 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: ) const replacementPath = path.resolve(dirname, './test-image.png') const metadata = await sharp(replacementPath).metadata() - const height = Math.floor(metadata.height! / 2) - const width = Math.floor(metadata.width! / 2) + const height = Math.floor(metadata.height / 2) + const width = Math.floor(metadata.width / 2) const { file, handle } = await createStreamableFile(replacementPath) const formData = new FormData() formData.append('_payload', JSON.stringify({ prefix: 'replacement' })) @@ -549,7 +549,7 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: expect(updatedDoc.filename).not.toBe(doc.filename) expect(updatedDoc.prefix).toBe('public') - expect(updatedDoc.sizes.thumbnail.filename).toBeTruthy() + expect(updatedDoc.variants.thumbnail.filename).toBeTruthy() }) test('should replace a file using its submitted prefix', async ({ payload, restClient }) => { @@ -586,7 +586,7 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: expect(updatedDoc.filename).not.toBe(doc.filename) expect(updatedDoc.prefix).toBe('replacement') - expect(updatedDoc.sizes.thumbnail.filename).toBeTruthy() + expect(updatedDoc.variants.thumbnail.filename).toBeTruthy() }) test('should retain current file data when restoring a version', async ({ @@ -645,7 +645,7 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: height: 1, mimeType: 'image/jpeg', prefix: otherDoc.prefix, - sizes: otherDoc.sizes, + variants: otherDoc.variants, thumbnailURL: '/version-thumbnail.jpg', url: { en: otherDoc.url, es: '/otro.png', fr: '/autre.png' }, width: 1, @@ -674,7 +674,7 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: expect(restoredDoc.height).toBe(currentDoc.height) expect(restoredDoc.mimeType).toBe(currentDoc.mimeType) expect(restoredDoc.prefix).toBe(currentDoc.prefix) - expect(restoredDoc.sizes).toEqual(currentDoc.sizes) + expect(restoredDoc.variants).toEqual(currentDoc.variants) expect(restoredDoc.thumbnailURL).toBe(currentDoc.thumbnailURL) expect(restoredDoc.url).toBe(currentDoc.url) expect(restoredDoc.width).toBe(currentDoc.width) @@ -732,7 +732,7 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: expect(doc.alt).toBe('Staged image') expect(doc.filename).toBe('staged-image.png') expect(doc.width).toBeDefined() - expect(doc.sizes.tablet.filename).toBeDefined() + expect(doc.variants.tablet.filename).toBeDefined() await payload.delete({ id: doc.id, collection: mediaSlug, overrideAccess: true }) }) @@ -896,29 +896,29 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: expect(response.status).toBe(201) - const { sizes } = doc + const { variants } = doc const expectedPath = path.join(dirname, './media') // Check for files expect(await fileExists(path.join(expectedPath, doc.filename))).toBe(true) expect( - await fileExists(path.join(expectedPath, sizes.maintainedAspectRatio.filename)), + await fileExists(path.join(expectedPath, variants.maintainedAspectRatio.filename)), ).toBe(true) - expect(await fileExists(path.join(expectedPath, sizes.tablet.filename))).toBe(true) - expect(await fileExists(path.join(expectedPath, sizes.mobile.filename))).toBe(true) - expect(await fileExists(path.join(expectedPath, sizes.icon.filename))).toBe(true) + expect(await fileExists(path.join(expectedPath, variants.tablet.filename))).toBe(true) + expect(await fileExists(path.join(expectedPath, variants.mobile.filename))).toBe(true) + expect(await fileExists(path.join(expectedPath, variants.icon.filename))).toBe(true) // Check api response expect(doc.mimeType).toEqual('image/png') expect(doc.focalX).toEqual(50) expect(doc.focalY).toEqual(50) - expect(sizes.maintainedAspectRatio.url).toContain('/api/media/file/image') - expect(sizes.maintainedAspectRatio.url).toContain('.png') - expect(sizes.maintainedAspectRatio.width).toEqual(1024) - expect(sizes.maintainedAspectRatio.height).toEqual(1024) - expect(sizes).toHaveProperty('tablet') - expect(sizes).toHaveProperty('mobile') - expect(sizes).toHaveProperty('icon') + expect(variants.maintainedAspectRatio.url).toContain('/api/media/file/image') + expect(variants.maintainedAspectRatio.url).toContain('.png') + expect(variants.maintainedAspectRatio.width).toEqual(1024) + expect(variants.maintainedAspectRatio.height).toEqual(1024) + expect(variants).toHaveProperty('tablet') + expect(variants).toHaveProperty('mobile') + expect(variants).toHaveProperty('icon') }) test('creates from a remote source without reusing submitted file identity', async ({ @@ -1000,13 +1000,13 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: expect(mediaDoc.url).not.toContain(' ') // Check that size URLs are also properly encoded - expect(mediaDoc.sizes?.tablet?.url).toBeDefined() - expect(mediaDoc.sizes?.tablet?.url).toContain('%20') - expect(mediaDoc.sizes?.tablet?.url).not.toContain(' ') + expect(mediaDoc.variants?.tablet?.url).toBeDefined() + expect(mediaDoc.variants?.tablet?.url).toContain('%20') + expect(mediaDoc.variants?.tablet?.url).not.toContain(' ') - expect(mediaDoc.sizes?.icon?.url).toBeDefined() - expect(mediaDoc.sizes?.icon?.url).toContain('%20') - expect(mediaDoc.sizes?.icon?.url).not.toContain(' ') + expect(mediaDoc.variants?.icon?.url).toBeDefined() + expect(mediaDoc.variants?.icon?.url).toContain('%20') + expect(mediaDoc.variants?.icon?.url).not.toContain(' ') await payload.delete({ id: mediaDoc.id, collection: mediaSlug, overrideAccess: true }) }) @@ -1033,7 +1033,7 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: // Check api response expect(doc.mimeType).toEqual('image/svg+xml') - expect(doc.sizes.maintainedAspectRatio.url).toBeFalsy() + expect(doc.variants.maintainedAspectRatio.url).toBeFalsy() expect(doc.width).toBeDefined() expect(doc.height).toBeDefined() }) @@ -1100,11 +1100,11 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: // Check for files expect(await fileExists(path.join(expectedPath, doc.filename))).toBe(true) expect(await fileExists(path.join(expectedPath, 'small-640x480.png'))).toBe(false) - expect(await fileExists(path.join(expectedPath, doc.sizes.icon.filename))).toBe(true) + expect(await fileExists(path.join(expectedPath, doc.variants.icon.filename))).toBe(true) // Check api response - expect(doc.sizes.tablet.filename).toBeNull() - expect(doc.sizes.icon.filename).toBeDefined() + expect(doc.variants.tablet.filename).toBeNull() + expect(doc.variants.icon.filename).toBeDefined() }) test('should not set url on image sizes that cannot be generated', async ({ @@ -1128,12 +1128,12 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: expect(response.status).toBe(201) // Check ungenerated sizes are empty, including the URL - expect(doc.sizes.tablet.filename).toBeNull() - expect(doc.sizes.tablet.width).toBeNull() - expect(doc.sizes.tablet.height).toBeNull() - expect(doc.sizes.tablet.mimeType).toBeNull() - expect(doc.sizes.tablet.filesize).toBeNull() - expect(doc.sizes.tablet.url).toBeNull() + expect(doc.variants.tablet.filename).toBeNull() + expect(doc.variants.tablet.width).toBeNull() + expect(doc.variants.tablet.height).toBeNull() + expect(doc.variants.tablet.mimeType).toBeNull() + expect(doc.variants.tablet.filesize).toBeNull() + expect(doc.variants.tablet.url).toBeNull() // Also verify the database const dbDoc = await payload.db.findOne({ @@ -1141,7 +1141,7 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: where: { id: { equals: doc.id } }, }) - expect(dbDoc.sizes.tablet.url).toBeNull() + expect(dbDoc.variants.tablet.url).toBeNull() }) test('creates images from a different format', async ({ restClient }) => { @@ -1164,15 +1164,15 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: // Check for files expect(await fileExists(path.join(expectedPath, doc.filename))).toBe(true) - expect(await fileExists(path.join(expectedPath, doc.sizes.tablet.filename))).toBe(true) + expect(await fileExists(path.join(expectedPath, doc.variants.tablet.filename))).toBe(true) // Check api response expect(doc.filename).toContain('.png') expect(doc.mimeType).toEqual('image/png') - expect(doc.sizes.maintainedAspectRatio.filename).toContain('.png') - expect(doc.sizes.maintainedAspectRatio.mimeType).toContain('image/png') - expect(doc.sizes.differentFormatFromMainImage.filename).toContain('.jpg') - expect(doc.sizes.differentFormatFromMainImage.mimeType).toContain('image/jpeg') + expect(doc.variants.maintainedAspectRatio.filename).toContain('.png') + expect(doc.variants.maintainedAspectRatio.mimeType).toContain('image/png') + expect(doc.variants.differentFormatFromMainImage.filename).toContain('.jpg') + expect(doc.variants.differentFormatFromMainImage.mimeType).toContain('image/jpeg') }) test('creates media without storing a file', async ({ restClient }) => { @@ -1275,9 +1275,9 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: const response = await restClient.PATCH(`/${mediaSlug}/${mediaDoc.id}`, { body: JSON.stringify({ filename: `archive/../${mediaDoc.filename}`, - sizes: { + variants: { icon: { - filename: `archive/../${mediaDoc.sizes.icon.filename}`, + filename: `archive/../${mediaDoc.variants.icon.filename}`, }, }, }), @@ -1292,7 +1292,7 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: })) as unknown as Media expect(unchangedDoc.filename).toBe(mediaDoc.filename) - expect(unchangedDoc.sizes.icon.filename).toBe(mediaDoc.sizes.icon.filename) + expect(unchangedDoc.variants.icon.filename).toBe(mediaDoc.variants.icon.filename) await payload.delete({ id: mediaDoc.id, collection: mediaSlug, overrideAccess: true }) }) @@ -1327,7 +1327,9 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: // Check that previously existing files were removed expect(await fileExists(path.join(expectedPath, mediaDoc.filename))).toBe(false) - expect(await fileExists(path.join(expectedPath, mediaDoc.sizes.icon.filename))).toBe(false) + expect(await fileExists(path.join(expectedPath, mediaDoc.variants.icon.filename))).toBe( + false, + ) }) test('should replace image and delete old files - where query', async ({ @@ -1370,7 +1372,9 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: // Check that previously existing files were removed expect(await fileExists(path.join(expectedPath, mediaDoc.filename))).toBe(false) - expect(await fileExists(path.join(expectedPath, mediaDoc.sizes.icon.filename))).toBe(false) + expect(await fileExists(path.join(expectedPath, mediaDoc.variants.icon.filename))).toBe( + false, + ) }) }) test.describe('delete', () => { @@ -1480,7 +1484,7 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: })) as unknown as Media try { - const size = mediaDoc.sizes!.differentFormatFromMainImage! + const size = mediaDoc.variants!.differentFormatFromMainImage! expect(size.mimeType).toBe('image/jpeg') @@ -1677,7 +1681,7 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: const svgFilePath = path.resolve(dirname, './svgWithXml.svg') const fileBuffer = fs.readFileSync(svgFilePath) - // SVGs cannot be resized, so sizes.small should have null fields + // SVGs cannot be resized, so variants.small should have null fields const doc = await payload.create({ collection: adminThumbnailSizeSlug as CollectionSlug, data: {}, @@ -2035,8 +2039,8 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: overrideAccess: true, })) as unknown as Media - expect(doc.sizes.icon).toBeDefined() - expect(doc.sizes.tablet.width).toBeNull() + expect(doc.variants.icon).toBeDefined() + expect(doc.variants.tablet.width).toBeNull() }) test('should remove sizes that do not pertain to the new image - where query', async ({ @@ -2063,8 +2067,8 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: }, })) as unknown as { docs: Media[] } - expect(doc.docs[0].sizes.icon).toBeDefined() - expect(doc.docs[0].sizes.tablet.width).toBeNull() + expect(doc.docs[0].variants.icon).toBeDefined() + expect(doc.docs[0].variants.tablet.width).toBeNull() }) test('should allow removing file from upload relationship field - by ID', async ({ @@ -2670,14 +2674,16 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: _internal_safeFetchGlobal.lookup = globalCachedFn // Now ensure this throws if we pass the IP address directly, without the mock + const allowedURLMessageMatcher = expect.not.stringContaining('unsafe') + const blockedURLMessageMatcher = expect.stringContaining(errorContains) const directURLFailure = collection === allowListMediaSlug ? { - message: expect.not.stringContaining('unsafe'), + message: allowedURLMessageMatcher, } : { name: 'FileRetrievalError', - message: expect.stringContaining(errorContains), + message: blockedURLMessageMatcher, } await expect( @@ -3042,9 +3048,9 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: expect(response.status).toBe(200) expect(doc).toMatchObject({ height: 800, width: 800 }) - expect(doc.sizes?.maintainedImageSize).toMatchObject({ height: 800, width: 800 }) + expect(doc.variants?.maintainedImageSize).toMatchObject({ height: 800, width: 800 }) - const sizePath = path.join(dirname, './media', doc.sizes!.maintainedImageSize!.filename!) + const sizePath = path.join(dirname, './media', doc.variants!.maintainedImageSize!.filename!) await expect(sharp(sizePath).metadata()).resolves.toMatchObject({ height: 800, @@ -3069,32 +3075,32 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: expect(result).toBeTruthy() - const { sizes } = result as unknown as Enlarge + const { variants } = result as unknown as Enlarge const expectedPath = path.join(dirname, './media/enlarge') // Check for files expect(await fileExists(path.join(expectedPath, small.name))).toBe(true) - expect(await fileExists(path.join(expectedPath, sizes.resizedLarger.filename))).toBe(true) - expect(await fileExists(path.join(expectedPath, sizes.resizedSmaller.filename))).toBe(true) - expect(await fileExists(path.join(expectedPath, sizes.accidentalSameSize.filename))).toBe( - true, - ) - expect(await fileExists(path.join(expectedPath, sizes.sameSizeWithNewFormat.filename))).toBe( + expect(await fileExists(path.join(expectedPath, variants.resizedLarger.filename))).toBe(true) + expect(await fileExists(path.join(expectedPath, variants.resizedSmaller.filename))).toBe(true) + expect(await fileExists(path.join(expectedPath, variants.accidentalSameSize.filename))).toBe( true, ) + expect( + await fileExists(path.join(expectedPath, variants.sameSizeWithNewFormat.filename)), + ).toBe(true) // Check api response - expect(sizes.sameSizeWithNewFormat.mimeType).toBe('image/jpeg') - expect(sizes.sameSizeWithNewFormat.filename).toBe('small-320x80.jpg') + expect(variants.sameSizeWithNewFormat.mimeType).toBe('image/jpeg') + expect(variants.sameSizeWithNewFormat.filename).toBe('small-320x80.jpg') - expect(sizes.resizedLarger.mimeType).toBe('image/png') - expect(sizes.resizedLarger.filename).toBe('small-640x480.png') + expect(variants.resizedLarger.mimeType).toBe('image/png') + expect(variants.resizedLarger.filename).toBe('small-640x480.png') - expect(sizes.resizedSmaller.mimeType).toBe('image/png') - expect(sizes.resizedSmaller.filename).toBe('small-180x50.png') + expect(variants.resizedSmaller.mimeType).toBe('image/png') + expect(variants.resizedSmaller.filename).toBe('small-180x50.png') - expect(sizes.accidentalSameSize.mimeType).toBe('image/png') - expect(sizes.accidentalSameSize.filename).toBe('small-320x80.png') + expect(variants.accidentalSameSize.mimeType).toBe('image/png') + expect(variants.accidentalSameSize.filename).toBe('small-320x80.png') await payload.delete({ id: result.id, @@ -3118,16 +3124,16 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: expect(result).toBeTruthy() - const { sizes } = result + const { variants } = result const expectedPath = path.join(dirname, './media/enlarge') // Check for files - expect(await fileExists(path.join(expectedPath, sizes.widthLowerHeightLarger.filename))).toBe( - true, - ) + expect( + await fileExists(path.join(expectedPath, variants.widthLowerHeightLarger.filename)), + ).toBe(true) // Check api response - expect(sizes.widthLowerHeightLarger.mimeType).toBe('image/png') - expect(sizes.widthLowerHeightLarger.filename).toBe('small-300x300.png') + expect(variants.widthLowerHeightLarger.mimeType).toBe('image/png') + expect(variants.widthLowerHeightLarger.filename).toBe('small-300x300.png') await payload.delete({ id: result.id, collection: enlargeSlug, @@ -3149,32 +3155,32 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: expect(result).toBeTruthy() - const { sizes } = result as unknown as Enlarge + const { variants } = result as unknown as Enlarge const expectedPath = path.join(dirname, './media/reduce') // Check for files expect(await fileExists(path.join(expectedPath, small.name))).toBe(true) expect(await fileExists(path.join(expectedPath, 'small-640x480.png'))).toBe(false) expect(await fileExists(path.join(expectedPath, 'small-180x50.png'))).toBe(false) - expect(await fileExists(path.join(expectedPath, sizes.accidentalSameSize.filename))).toBe( - true, - ) - expect(await fileExists(path.join(expectedPath, sizes.sameSizeWithNewFormat.filename))).toBe( + expect(await fileExists(path.join(expectedPath, variants.accidentalSameSize.filename))).toBe( true, ) + expect( + await fileExists(path.join(expectedPath, variants.sameSizeWithNewFormat.filename)), + ).toBe(true) // Check api response - expect(sizes.sameSizeWithNewFormat.mimeType).toBe('image/jpeg') - expect(sizes.sameSizeWithNewFormat.filename).toBe('small-320x80.jpg') + expect(variants.sameSizeWithNewFormat.mimeType).toBe('image/jpeg') + expect(variants.sameSizeWithNewFormat.filename).toBe('small-320x80.jpg') - expect(sizes.resizedLarger.mimeType).toBeNull() - expect(sizes.resizedLarger.filename).toBeNull() + expect(variants.resizedLarger.mimeType).toBeNull() + expect(variants.resizedLarger.filename).toBeNull() - expect(sizes.accidentalSameSize.mimeType).toBe('image/png') - expect(sizes.resizedSmaller.filename).toBe('small-320x80.png') + expect(variants.accidentalSameSize.mimeType).toBe('image/png') + expect(variants.resizedSmaller.filename).toBe('small-320x80.png') - expect(sizes.accidentalSameSize.mimeType).toBe('image/png') - expect(sizes.accidentalSameSize.filename).toBe('small-320x80.png') + expect(variants.accidentalSameSize.mimeType).toBe('image/png') + expect(variants.accidentalSameSize.filename).toBe('small-320x80.png') await payload.delete({ id: result.id, collection: reduceSlug, overrideAccess: true }) }) @@ -3193,9 +3199,9 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: expect(result).toBeTruthy() - const { sizes } = result as unknown as Enlarge + const { variants } = result as unknown as Enlarge - expect(sizes.undefinedHeightWithoutEnlargement).toMatchObject({ + expect(variants.undefinedHeightWithoutEnlargement).toMatchObject({ filename: null, filesize: null, height: null, @@ -3213,7 +3219,7 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: // A wrong per-frame divisor on the 44-frame, 200x200 animated.webp would report the frame // stack's height or fail the resize, so exact single-frame dimensions prove both paths. - test('should report single-frame dimensions for an animated image and its sizes', async ({ + test('should report single-frame dimensions for an animated image and its variants', async ({ payload, }) => { const doc = await payload.create({ @@ -3225,7 +3231,7 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: expect(doc).toMatchObject({ height: 200, - sizes: { + variants: { focalCrop: { height: 150, width: 300 }, // A real enlargement (200x200 -> 480x480), not a no-op. squareSmall: { height: 480, width: 480 }, @@ -3393,8 +3399,8 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: // payload.find should return full URLs with serverURL prefix (through afterRead hooks) expect(mediaDoc.url).toContain('http://local-images:3000') - expect(mediaDoc.sizes?.tablet?.url).toContain('http://local-images:3000') - expect(mediaDoc.sizes?.icon?.url).toContain('http://local-images:3000') + expect(mediaDoc.variants?.tablet?.url).toContain('http://local-images:3000') + expect(mediaDoc.variants?.icon?.url).toContain('http://local-images:3000') // Direct database query should return relative URLs (no hooks applied) const dbDoc = (await payload.db.findOne({ @@ -3412,13 +3418,13 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: expect(dbDoc.url).toMatch(/^\/api\/media\/file\//) // Check that size URLs are also relative in the database - expect(dbDoc.sizes?.tablet?.url).toBeDefined() - expect(dbDoc.sizes?.tablet?.url).not.toContain('http://local-images:3000') - expect(dbDoc.sizes?.tablet?.url).toMatch(/^\/api\/media\/file\//) + expect(dbDoc.variants?.tablet?.url).toBeDefined() + expect(dbDoc.variants?.tablet?.url).not.toContain('http://local-images:3000') + expect(dbDoc.variants?.tablet?.url).toMatch(/^\/api\/media\/file\//) - expect(dbDoc.sizes?.icon?.url).toBeDefined() - expect(dbDoc.sizes?.icon?.url).not.toContain('http://local-images:3000') - expect(dbDoc.sizes?.icon?.url).toMatch(/^\/api\/media\/file\//) + expect(dbDoc.variants?.icon?.url).toBeDefined() + expect(dbDoc.variants?.icon?.url).not.toContain('http://local-images:3000') + expect(dbDoc.variants?.icon?.url).toMatch(/^\/api\/media\/file\//) await payload.delete({ id: mediaDoc.id, collection: mediaSlug, overrideAccess: true }) } finally { @@ -3481,9 +3487,9 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: expect(dbDoc.url).toMatch(/^\/api\/media\/file\//) // Check that size URLs are also relative in the database - expect(dbDoc.sizes?.tablet?.url).toBeDefined() - expect(dbDoc.sizes?.tablet?.url).not.toContain('http://local-images:3000') - expect(dbDoc.sizes?.tablet?.url).toMatch(/^\/api\/media\/file\//) + expect(dbDoc.variants?.tablet?.url).toBeDefined() + expect(dbDoc.variants?.tablet?.url).not.toContain('http://local-images:3000') + expect(dbDoc.variants?.tablet?.url).toMatch(/^\/api\/media\/file\//) await payload.delete({ id: mediaDoc.id, collection: mediaSlug, overrideAccess: true }) await payload.delete({ id: duplicatedDoc.id, collection: mediaSlug, overrideAccess: true }) @@ -3547,9 +3553,9 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: expect(dbDoc.url).toMatch(/^\/api\/media\/file\//) // Check that size URLs are also relative in the database - expect(dbDoc.sizes?.tablet?.url).toBeDefined() - expect(dbDoc.sizes?.tablet?.url).not.toContain('http://local-images:3000') - expect(dbDoc.sizes?.tablet?.url).toMatch(/^\/api\/media\/file\//) + expect(dbDoc.variants?.tablet?.url).toBeDefined() + expect(dbDoc.variants?.tablet?.url).not.toContain('http://local-images:3000') + expect(dbDoc.variants?.tablet?.url).toMatch(/^\/api\/media\/file\//) await payload.delete({ id: mediaDoc.id, collection: mediaSlug, overrideAccess: true }) } finally { @@ -3971,13 +3977,16 @@ test.suite('Collections - Uploads', { config: './config.ts', resetBetweenTests: test.each([ { description: 'absolute path like /tmp', dir: '/tmp', expectedPrefix: '/tmp' }, { description: 'relative path', dir: 'tmp', expectedPrefix: path.join(process.cwd(), 'tmp') }, - ])('creates temp files in correct location for $description', ({ dir, expectedPrefix }) => { - const handler = tempFileHandler({ tempFileDir: dir }, 'field', 'file.png') - const filePath = handler.getFilePath() - - expect(filePath.startsWith(expectedPrefix)).toBe(true) - handler.cleanup() - }) + ])( + 'creates temp files in correct location for $description', + async ({ dir, expectedPrefix }) => { + const handler = tempFileHandler({ tempFileDir: dir }, 'field', 'file.png') + const filePath = handler.getFilePath() + + expect(filePath.startsWith(expectedPrefix)).toBe(true) + await handler.cleanup() + }, + ) }) test.describe('prefix query parameter', () => { diff --git a/test/uploads/payload-types.ts b/test/uploads/payload-types.ts index 72539c7e4c6..4d59f61cc84 100644 --- a/test/uploads/payload-types.ts +++ b/test/uploads/payload-types.ts @@ -404,7 +404,7 @@ export interface Media { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { maintainedAspectRatio?: { url?: string | null; width?: number | null; @@ -708,7 +708,7 @@ export interface AdminThumbnailSize { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { small?: { url?: string | null; width?: number | null; @@ -781,7 +781,7 @@ export interface GifResize { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { small?: { url?: string | null; width?: number | null; @@ -829,7 +829,7 @@ export interface FilenameCompoundIndex { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { small?: { url?: string | null; width?: number | null; @@ -899,7 +899,7 @@ export interface ObjectFit { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { fitContain?: { url?: string | null; width?: number | null; @@ -959,7 +959,7 @@ export interface WithMetaDatum { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { sizeOne?: { url?: string | null; width?: number | null; @@ -995,7 +995,7 @@ export interface WithoutMetaDatum { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { sizeTwo?: { url?: string | null; width?: number | null; @@ -1031,7 +1031,7 @@ export interface WithOnlyJpegMetaDatum { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { sizeThree?: { url?: string | null; width?: number | null; @@ -1067,7 +1067,7 @@ export interface CropOnly { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { focalTest?: { url?: string | null; width?: number | null; @@ -1119,7 +1119,7 @@ export interface FocalOnly { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { focalTest?: { url?: string | null; width?: number | null; @@ -1171,7 +1171,7 @@ export interface ImageSizesOnly { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { sizeOne?: { url?: string | null; width?: number | null; @@ -1475,7 +1475,7 @@ export interface AnimatedTypeMedia { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { squareSmall?: { url?: string | null; width?: number | null; @@ -1543,7 +1543,7 @@ export interface Enlarge { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { accidentalSameSize?: { url?: string | null; width?: number | null; @@ -1645,7 +1645,7 @@ export interface Reduce { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { accidentalSameSize?: { url?: string | null; width?: number | null; @@ -1705,7 +1705,7 @@ export interface MediaTrim { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { trimNumber?: { url?: string | null; width?: number | null; @@ -1757,7 +1757,7 @@ export interface CustomFileNameMedia { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { custom?: { url?: string | null; width?: number | null; @@ -2557,7 +2557,7 @@ export interface MediaWithImageSizeAdminProp { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { one?: { url?: string | null; width?: number | null; @@ -2647,7 +2647,7 @@ export interface FileAccessMedia { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { thumbnail?: { url?: string | null; width?: number | null; @@ -2716,7 +2716,7 @@ export interface MediaWithField { height?: number | null; focalX?: number | null; focalY?: number | null; - sizes?: { + variants?: { thumbnail?: { url?: string | null; width?: number | null; @@ -3302,7 +3302,7 @@ export interface GifResizeSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { small?: @@ -3346,7 +3346,7 @@ export interface FilenameCompoundIndexSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { small?: @@ -3408,7 +3408,7 @@ export interface ObjectFitSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { fitContain?: @@ -3471,7 +3471,7 @@ export interface WithMetaDataSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { sizeOne?: @@ -3504,7 +3504,7 @@ export interface WithoutMetaDataSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { sizeTwo?: @@ -3537,7 +3537,7 @@ export interface WithOnlyJpegMetaDataSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { sizeThree?: @@ -3570,7 +3570,7 @@ export interface CropOnlySelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { focalTest?: @@ -3623,7 +3623,7 @@ export interface FocalOnlySelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { focalTest?: @@ -3676,7 +3676,7 @@ export interface ImageSizesOnlySelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { sizeOne?: @@ -3740,7 +3740,7 @@ export interface MediaSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { maintainedAspectRatio?: @@ -4094,7 +4094,7 @@ export interface AnimatedTypeMediaSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { squareSmall?: @@ -4167,7 +4167,7 @@ export interface EnlargeSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { accidentalSameSize?: @@ -4269,7 +4269,7 @@ export interface ReduceSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { accidentalSameSize?: @@ -4332,7 +4332,7 @@ export interface MediaTrimSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { trimNumber?: @@ -4385,7 +4385,7 @@ export interface CustomFileNameMediaSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { custom?: @@ -4558,7 +4558,7 @@ export interface AdminThumbnailSizeSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { small?: @@ -5098,7 +5098,7 @@ export interface MediaWithImageSizeAdminPropsSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { one?: @@ -5184,7 +5184,7 @@ export interface FileAccessMediaSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { thumbnail?: @@ -5256,7 +5256,7 @@ export interface MediaWithFieldsSelect { height?: T; focalX?: T; focalY?: T; - sizes?: + variants?: | T | { thumbnail?: