-
Notifications
You must be signed in to change notification settings - Fork 3.5k
Description
Bug Description
payload.delete({ collection, where }) reports success (returns correct IDs) but silently fails to delete 1 or more matching records. Verified with raw SQL immediately after the call — the records still exist in the database.
This is a regression introduced between 3.41.0 and 3.80.0. We confirmed 3.41.0 does not have this bug.
Root Cause
In dist/collections/operations/delete.js, the bulk delete operation uses .map(async ...) to create promises for each document, then either Promise.all or for...of await to process them:
// Line 82: .map() eagerly starts ALL async callbacks
const promises = docs.map(async (doc) => {
// ...
await payload.db.deleteOne({ where: { id: { equals: id } } });
});
// Line 234-242: "Sequential" mode still doesn't help
if (req.payload.db.bulkOperationsSingleTransaction) {
for (const promise of promises) { // promises already started!
awaitedDocs.push(await promise);
}
} else {
awaitedDocs = await Promise.all(promises);
}The issue: .map(async ...) starts ALL async callbacks immediately. The for...of await loop only controls the order results are collected, not the order of execution. All deleteOne calls run concurrently on the same transaction connection, each performing 3 queries (selectDistinct → findFirst → deleteWhere) that interleave and corrupt each other.
Setting bulkOperationsSingleTransaction: true does not fix the issue because of this same .map() antipattern.
Fix
Replace .map() + for...of await with a proper sequential loop:
const processDoc = async (doc) => { /* existing callback body */ };
let awaitedDocs = [];
for (const doc of docs) {
awaitedDocs.push(await processDoc(doc));
}This ensures each deleteOne completes before the next one starts.
Reproduction
Self-contained script (uses vehicle-requirements — a collection with zero hooks):
import { getPayload } from 'payload';
import config from './src/payload.config';
import { sql } from 'drizzle-orm';
const payload = await getPayload({ config });
const db = payload.db.drizzle;
// Create 5 records
const testIds = [];
for (let i = 0; i < 5; i++) {
const doc = await payload.create({
collection: 'vehicle-requirements',
data: { jobPlan: PLAN_ID, jobPlanDay: DAY_ID, vehicleType: `test-${i}`, quantity: 1 },
});
testIds.push(doc.id);
}
// Delete via where
const result = await payload.delete({
collection: 'vehicle-requirements',
where: { id: { in: testIds } },
});
console.log('Payload says deleted:', result.docs.length); // 5
// Verify via raw SQL
const remaining = await db.execute(
sql`SELECT id FROM vehicle_requirements WHERE id IN (${sql.join(testIds.map(id => sql`${id}`), sql`, `)})`
);
console.log('Actually remaining:', remaining.rows.length); // 1 (BUG)Reproduces within 1-3 iterations consistently on Payload 3.80.0 with @payloadcms/db-postgres.
Control test: Drizzle direct db.delete(table).where(inArray(...)) works perfectly — 0 failures across all iterations. The bug is in Payload's abstraction layer, not Drizzle or Postgres.
Environment
- payload: 3.80.0
- @payloadcms/db-postgres: 3.80.0
- @payloadcms/drizzle: 3.80.0
- PostgreSQL 15
- Node 22
- macOS
Related
- Bulk Delete Leaves One Item #15100 (closed as can't-reproduce, but we can reproduce consistently)