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):
npx create-payload-app with the Postgres adapter.
- 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.
- Create ~10 documents. Publish each, then save a draft change to each, so every document has both a published version and a newer draft.
- Put some volume in the block field on each document so the version bodies are not trivial.
- 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):
- Same collection shape, on
@payloadcms/db-d1-sqlite.
- Create 50 documents that each have a draft and a published version, so all 50 ids land in the
parent: { in: [...] } above.
- 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
Describe the Bug
Two independent defects in
@payloadcms/drizzlethat 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.jsandbuildFindManyArgs.jsare byte-identical between those two releases, so one fix serves the Postgres and D1 adapters alike.Defect 1 — a
selecton a version query is silently discardedWhere:
packages/drizzle/src/find/traverseFields.ts,case 'group': case 'tab':Version tables wrap the entire document under a
versiongroup. A caller selecting a top-level column — say{ parent: true }— leavesversionunselected, so the recursion into that group continues withselect: undefined. Every downstream branch reads a falsyselectas "select everything": thecase 'blocks'guard sits insideif (select),case 'array'likewise, and the scalar default bails out early.The net effect is that
selecton 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.tsmarks a drafted row as "changed" when the document also has a published version, and finds those with: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:
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.tscarries a TODO naming afindDistinctVersions()API that does not exist yet. Worth flagging thatfindDistinct.jsalready 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 —
inbinds every id twiceWhere:
packages/drizzle/src/queries/sanitizeQueryValue.tsEach 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:
D1_ERROR: too many SQL variablesadapter.idTypeis already known at that point, so the second spelling is redundant whenever the column type is determined.Related, same family
deleteVersionsloads every stale version's full body viafindManywith noselectbefore deleting, unchunked, andenforceMaxVersionsawaits 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 patchon@payloadcms/nextthat 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/drizzleinstead, since it is the true root cause and would fix every adapter at once — butbuildFindManyArgsis also called byupsertRow(twice) anddeleteOne, i.e. the write path of every collection and global, andfindVersionsdoes not pass aversions: trueflag 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
traverseFieldswith 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 freshcreate-payload-appproject, or dropped straight into the existingpackages/drizzletest 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):
npx create-payload-appwith the Postgres adapter.versions: { drafts: true }and ablocksfield with several block types, each carrying a rich-text or text field. The effect scales with the number of block tables; ours has 16.log_statement = 'all', or apgpool wrapper).Expected: the query behind
enrichDocsWithVersionStatusselectsparentonly, per its ownselect: { 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):
@payloadcms/db-d1-sqlite.parent: { in: [...] }above.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