Skip to content

Version queries silently discard select, and in binds every id twice #18251

Description

@p11yadrian

Describe the Bug

Two independent defects in @payloadcms/drizzle that compound on Payload's own admin list view. Together they produced an OOM crash-loop on a production instance. No custom code is involved — the query that triggers it is one Payload issues itself.

Reproduced on 3.85.2 and 3.86.0. traverseFields.js and buildFindManyArgs.js are byte-identical between those two releases, so one fix serves the Postgres and D1 adapters alike.


Defect 1 — a select on a version query is silently discarded

Where: packages/drizzle/src/find/traverseFields.ts, case 'group': case 'tab':

select: typeof fieldSelect === 'object' ? fieldSelect : undefined

Version tables wrap the entire document under a version group. A caller selecting a top-level column — say { parent: true } — leaves version unselected, so the recursion into that group continues with select: undefined. Every downstream branch reads a falsy select as "select everything": the case 'blocks' guard sits inside if (select), case 'array' likewise, and the scalar default bails out early.

The net effect is that select on a version query is not a narrowing at all — the query returns every block, array and locale row of every matching version.

Who hits it: the admin list view. packages/next/src/views/List/enrichDocsWithVersionStatus.ts marks a drafted row as "changed" when the document also has a published version, and finds those with:

await req.payload.findVersions({
  collection: collectionConfig.slug,
  depth: 0,
  limit: 0,
  pagination: false,
  select: { parent: true },
  where: {
    and: [
      { parent: { in: draftDocIds } },
      { 'version._status': { equals: 'published' } },
    ],
  },
})

select: { parent: true } is exactly the shape that lands in the hole. The comment above it says the select exists "to minimize data transfer" — it does not.

Measurements

Postgres, a block-heavy collection (16 block types), ten drafted documents on one page of the list view:

version rows returned 199
payload 15.8 MB
duration 691 ms
block tables joined all 16, despite the select

On a second instance with heavier version history the same page cost 141 MB and 15 s per view. Because the list view re-runs on every navigation and reload, the admin never recovered — that is the crash-loop.

Note on the intended fix

enrichDocsWithVersionStatus.ts carries a TODO naming a findDistinctVersions() API that does not exist yet. Worth flagging that findDistinct.js already exists for main collections — so the plan appears to be a new API routing around a bug in an existing one, rather than fixing the select fidelity that every other caller also depends on.


Defect 2 — in binds every id twice

Where: packages/drizzle/src/queries/sanitizeQueryValue.ts

Each id is expanded into both of its possible id-type spellings (push(val, String(val)) for numeric ids) and never deduped.

Harmless on Postgres. On Cloudflare D1, whose limit is 100 bound variables per statement, it halves the usable id budget:

ids bound params result
49 99 ok
50 100 D1_ERROR: too many SQL variables

adapter.idType is already known at that point, so the second spelling is redundant whenever the column type is determined.


Related, same family

deleteVersions loads every stale version's full body via findMany with no select before deleting, unchunked, and enforceMaxVersions awaits it inside the write's own transaction (saveVersion.ts).

Measured: one save on an 87-version document loaded 63 full bodies — +115 MB heap, 800 ms, inside the user's save request.


What we did meanwhile

Three of our repos carry a pnpm patch on @payloadcms/next that asks the database for the distinct parent ids directly, keeping the upstream query as a fallback for adapters that expose no pool. We would much rather delete those patches.

We considered patching @payloadcms/drizzle instead, since it is the true root cause and would fix every adapter at once — but buildFindManyArgs is also called by upsertRow (twice) and deleteOne, i.e. the write path of every collection and global, and findVersions does not pass a versions: true flag today, so a versions-only gate is not free from the outside. That judgement is exactly the kind that belongs upstream rather than in a consumer patch.

Happy to open a PR against traverseFields with a select-fidelity test if that is a welcome shape for the fix.

Link to the code that reproduces this issue

No separate reproduction repo — no custom code is required, and I did not want to imply otherwise with a repo that just wraps create-payload-app. Both defects are reachable from a stock install using only Payload's own admin UI, and both are visible by reading the shipped source at the two file/line references above. The steps below are written so they can be followed against a fresh create-payload-app project, or dropped straight into the existing packages/drizzle test suite.

Glad to publish a minimal repo if that is required for triage — say the word and I will put one up.

Reproduction Steps

Defect 1 (Postgres, or any Drizzle adapter):

  1. npx create-payload-app with the Postgres adapter.
  2. Add a collection with versions: { drafts: true } and a blocks field with several block types, each carrying a rich-text or text field. The effect scales with the number of block tables; ours has 16.
  3. Create ~10 documents. Publish each, then save a draft change to each, so every document has both a published version and a newer draft.
  4. Put some volume in the block field on each document so the version bodies are not trivial.
  5. Open the admin list view for that collection and capture the SQL the adapter emits (e.g. log_statement = 'all', or a pg pool wrapper).

Expected: the query behind enrichDocsWithVersionStatus selects parent only, per its own select: { parent: true }.

Actual: the emitted SQL joins every block table for the version rows and returns each version's full body. With the numbers above: 199 rows, 15.8 MB, 691 ms for a single page of ten rows.

Defect 2 (Cloudflare D1):

  1. Same collection shape, on @payloadcms/db-d1-sqlite.
  2. Create 50 documents that each have a draft and a published version, so all 50 ids land in the parent: { in: [...] } above.
  3. Open the list view.

Expected: the page loads.

Actual: D1_ERROR: too many SQL variables. At 49 documents it succeeds (99 bound params). The 50th tips it to 100 because every id is bound twice.

Which area(s) are affected? (Select all that apply)

area: core, db: postgres, db: d1-sqlite

Environment Info

Payload: 3.86.0 (also reproduced on 3.85.2)
Node.js: 22.23.0
Next.js: 16.3.4
React: 19.2.4
Adapters: @payloadcms/db-postgres 3.86.0, @payloadcms/db-d1-sqlite 3.85.2

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions