Skip to content

generate:db-schema emits an enum for _status columns the database holds as varchar (two causes) #17738

Description

@EugenieF

Summary

Two independent cases where generate:db-schema emits an enum for a _status column that the database does not actually have as an enum. Filed together because they are the same theme, and a fix for either affects how the other should be resolved.

  1. A user-declared _status field has its options concatenated onto the injected ones, producing an enum Postgres rejects outright.
  2. The localizeStatus migration helper creates the column as varchar, while the generator declares an enum for it.

With push: false the generated schema is never executed, so neither surfaces at the point the mismatch appears — it surfaces later, as a hand-written migration built from a wrong mirror.


Case 1 — a redeclared _status concatenates its options, producing an invalid enum

This one also reaches payload-types.ts, which application code compiles against.

Reproducing it

A Postgres collection with drafts enabled that redeclares _status — we did this to attach a custom list Cell:

export const Post: CollectionConfig = {
  slug: 'post',
  versions: { drafts: true },
  fields: [
    {
      name: '_status',
      type: 'select',
      options: [
        { label: 'Draft', value: 'draft' },
        { label: 'Published', value: 'published' },
      ],
      admin: { components: { Cell: '/components/StatusCell' } },
    },
  ],
}
payload generate:db-schema
payload generate:types

Actual

// payload-generated-schema.ts
export const enum_post_status = pgEnum('enum_post_status', [
  'draft', 'published', 'draft', 'published',
])
export const enum__post_v_version_status = pgEnum('enum__post_v_version_status', [
  'draft', 'published', 'draft', 'published',
])
// payload-types.ts
_status?: ('draft' | 'published' | 'draft' | 'published') | null;

Expected ['draft', 'published'] and ('draft' | 'published') | null.

Why it matters

The generated DDL cannot be executed. On postgres:17:

postgres=# CREATE TYPE dup AS ENUM('draft','published','draft','published');
ERROR:  duplicate key value violates unique constraint "pg_enum_typid_label_index"
DETAIL:  Key (enumtypid, enumlabel)=(16385, draft) already exists.

Separately from the SQL, the malformed union in payload-types.ts is what application code is typed against.

Cause

sanitizeCollection merges the user's fields with baseVersionFields:

https://github.com/payloadcms/payload/blob/v3.85.2/packages/payload/src/collections/config/sanitize.ts#L258-L263

sanitized.fields = mergeBaseFields(
  sanitized.fields,
  baseVersionFields({
    localized: sanitized.versions.drafts.localizeStatus ?? false,
  }),
)

mergeBaseFields merges each matching field with deepMergeWithReactComponents:

https://github.com/payloadcms/payload/blob/v3.85.2/packages/payload/src/fields/mergeBaseFields.ts#L26

which does not set arrayMerge, so deepmerge's default — concatenation — applies:

https://github.com/payloadcms/payload/blob/v3.85.2/packages/payload/src/utilities/deepMerge.ts#L47-L51

export function deepMergeWithReactComponents<T extends object>(obj1: object, obj2: object): T {
  return deepMerge<T>(obj1, obj2, {
    isMergeableObject: isPlainObject,
  })
}

baseVersionFields already carries options: statuses:

https://github.com/payloadcms/payload/blob/v3.85.2/packages/payload/src/versions/baseFields.ts#L29

so a user-supplied options array is appended rather than replacing it.

In practice only _status is affected — it is the only base field carrying an options array that users routinely redeclare. Collections that do not redeclare it are unaffected.

Workaround

options: [] on the redeclared field. The key cannot be omitted; options is required by the SelectField type.

Possible fix

A replace-not-concat strategy for options when merging base fields. Note that deepMergeWithSourceArrays already exists in the same module and does exactly this. Concatenation cannot produce a valid result for any enum-backed field, so overriding seems like the only sensible reading of a user redeclaring one — but deepMergeWithReactComponents has other callers, so the safer change is probably at the mergeBaseFields call site rather than in the shared helper. Happy to send a PR if you have a preference on which.


Case 2 — localizeStatus creates varchar, the generator declares an enum

Reproducing it

With experimental.localizeStatus: true on Postgres, run Payload's own helper:

import { localizeStatus } from 'payload/migrations'
await localizeStatus.up({ collectionSlug: 'post', db: payload.db, payload, req, sql })

then payload generate:db-schema.

Actual

The helper creates varchar:

https://github.com/payloadcms/payload/blob/v3.85.2/packages/payload/src/versions/migrations/localizeStatus/sql/up.ts#L264

ADD COLUMN _status VARCHAR DEFAULT 'draft'

https://github.com/payloadcms/payload/blob/v3.85.2/packages/payload/src/versions/migrations/localizeStatus/sql/up.ts#L157

ALTER TABLE ${sql.identifier(localesTable)} ADD COLUMN version__status VARCHAR

The generator declares an enum for those same columns:

post_locales._status            -> enum_post_status
_post_v_locales.version__status -> enum__post_v_version_status

information_schema reports character varying for all of them. The enum types are still created, but end up orphaned — nothing references them.

In our project this affects six columns: _status on work_locales / collection_locales / author_locales, and version__status on the three corresponding _v_locales tables.

Why it matters

Anyone hand-writing DDL from the generated mirror writes an enum column where the database has a varchar, and it recurs for every collection anyone localizes status on.

Expected

The helper and the generator should agree — either the helper creates and uses the enum type, or the generator emits varchar for localized _status. We have accepted the database side and documented the divergence, so this is not blocking us; which side should change looks like a genuine design decision rather than an obvious bug, which is why it is filed here for discussion rather than with a patch.

Version

Case 1 was found on 3.85.2, and generate:db-schema output is byte-identical between 3.85.2 and 3.87.1 (verified by direct diff during an upgrade); the cited sanitize.ts / mergeBaseFields.ts / deepMerge.ts code is also unchanged on main at the time of writing, though sanitize.ts has shifted to L286.

Case 2 was observed on 3.85.2 only. localizeStatus is a migration helper rather than generator output, so it was not part of that comparison and I have not checked it across versions.


Common thread

In both cases the generated schema is the only wrong artifact, and push: false means nothing ever executes it to surface the disagreement. A CI job that regenerates the file catches staleness but by construction cannot catch either of these — the generator reproduces its own bugs deterministically.

A third instance of the same shape, the locale enum being declared as enum__locales when the adapter creates _locales, is filed separately as #17736, with a patch in #17737, since that one needs no design decision.

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions