Files
payload/test/database/postgres-logs.int.spec.ts
Alessio Gravili 5ded64eaaf feat(db-*): support atomic array $push db updates (#13453)
This PR adds **atomic** `$push` **support for array fields**. It makes
it possible to safely append new items to arrays, which is especially
useful when running tasks in parallel (like job queues) where multiple
processes might update the same record at the same time. By handling
pushes atomically, we avoid race conditions and keep data consistent -
especially on postgres, where the current implementation would nuke the
entire array table before re-inserting every single array item.

The feature works for both localized and unlocalized arrays, and
supports pushing either single or multiple items at once.

This PR is a requirement for reliably running parallel tasks in the job
queue - see https://github.com/payloadcms/payload/pull/13452.

Alongside documenting `$push`, this PR also adds documentation for
`$inc`.

## Changes to updatedAt behavior

https://github.com/payloadcms/payload/pull/13335 allows us to override
the updatedAt property instead of the db always setting it to the
current date.

However, we are not able to skip updating the updatedAt property
completely. This means, usage of $push results in 2 postgres db calls:
1. set updatedAt in main row
2. append array row in arrays table

This PR changes the behavior to only automatically set updatedAt if it's
undefined. If you explicitly set it to `null`, this now allows you to
skip the db adapter automatically setting updatedAt.

=> This allows us to use $push in just one single db call

## Usage Examples

### Pushing a single item to an array

```ts
const post = (await payload.db.updateOne({
  data: {
    array: {
      $push: {
        text: 'some text 2',
        id: new mongoose.Types.ObjectId().toHexString(),
      },
    },
  },
  collection: 'posts',
  id: post.id,
}))
```

### Pushing a single item to a localized array

```ts
const post = (await payload.db.updateOne({
  data: {
    arrayLocalized: {
      $push: {
        en: {
          text: 'some text 2',
          id: new mongoose.Types.ObjectId().toHexString(),
        },
        es: {
          text: 'some text 2 es',
          id: new mongoose.Types.ObjectId().toHexString(),
        },
      },
    },
  },
  collection: 'posts',
  id: post.id,
}))
```

### Pushing multiple items to an array

```ts
const post = (await payload.db.updateOne({
  data: {
    array: {
      $push: [
        {
          text: 'some text 2',
          id: new mongoose.Types.ObjectId().toHexString(),
        },
        {
          text: 'some text 3',
          id: new mongoose.Types.ObjectId().toHexString(),
        },
      ],
    },
  },
  collection: 'posts',
  id: post.id,
}))
```

### Pushing multiple items to a localized array

```ts
const post = (await payload.db.updateOne({
  data: {
    arrayLocalized: {
      $push: {
        en: {
          text: 'some text 2',
          id: new mongoose.Types.ObjectId().toHexString(),
        },
        es: [
          {
            text: 'some text 2 es',
            id: new mongoose.Types.ObjectId().toHexString(),
          },
          {
            text: 'some text 3 es',
            id: new mongoose.Types.ObjectId().toHexString(),
          },
        ],
      },
    },
  },
  collection: 'posts',
  id: post.id,
}))
```

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1211110462564647
2025-08-27 14:11:08 -04:00

225 lines
5.8 KiB
TypeScript

import type { Payload } from 'payload'
/* eslint-disable jest/require-top-level-describe */
import assert from 'assert'
import mongoose from 'mongoose'
import path from 'path'
import { fileURLToPath } from 'url'
import type { Post } from './payload-types.js'
import { initPayloadInt } from '../helpers/initPayloadInt.js'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
const describePostgres = process.env.PAYLOAD_DATABASE?.startsWith('postgres')
? describe
: describe.skip
let payload: Payload
describePostgres('database - postgres logs', () => {
beforeAll(async () => {
const initialized = await initPayloadInt(
dirname,
undefined,
undefined,
'config.postgreslogs.ts',
)
assert(initialized.payload)
assert(initialized.restClient)
;({ payload } = initialized)
})
afterAll(async () => {
await payload.destroy()
})
it('ensure simple update uses optimized upsertRow with returning()', async () => {
const doc = await payload.create({
collection: 'simple',
data: {
text: 'Some title',
number: 5,
},
})
// Count every console log
const consoleCount = jest.spyOn(console, 'log').mockImplementation(() => {})
const result: any = await payload.db.updateOne({
collection: 'simple',
id: doc.id,
data: {
text: 'Updated Title',
number: 5,
},
})
expect(result.text).toEqual('Updated Title')
expect(result.number).toEqual(5) // Ensure the update did not reset the number field
expect(consoleCount).toHaveBeenCalledTimes(1) // Should be 1 single sql call if the optimization is used. If not, this would be 2 calls
consoleCount.mockRestore()
})
it('ensure simple update of complex collection uses optimized upsertRow without returning()', async () => {
const doc = await payload.create({
collection: 'posts',
data: {
title: 'Some title',
number: 5,
},
})
// Count every console log
const consoleCount = jest.spyOn(console, 'log').mockImplementation(() => {})
const result: any = await payload.db.updateOne({
collection: 'posts',
id: doc.id,
data: {
title: 'Updated Title',
number: 5,
},
})
expect(result.title).toEqual('Updated Title')
expect(result.number).toEqual(5) // Ensure the update did not reset the number field
expect(consoleCount).toHaveBeenCalledTimes(2) // Should be 2 sql call if the optimization is used (update + find). If not, this would be 5 calls
consoleCount.mockRestore()
})
it('ensure deleteMany is done in single db query - no where query', async () => {
await payload.create({
collection: 'posts',
data: {
title: 'Some title',
number: 5,
},
})
await payload.create({
collection: 'posts',
data: {
title: 'Some title 2',
number: 5,
},
})
await payload.create({
collection: 'posts',
data: {
title: 'Some title 2',
number: 5,
},
})
// Count every console log
const consoleCount = jest.spyOn(console, 'log').mockImplementation(() => {})
await payload.db.deleteMany({
collection: 'posts',
where: {},
})
expect(consoleCount).toHaveBeenCalledTimes(1)
consoleCount.mockRestore()
const allPosts = await payload.find({
collection: 'posts',
})
expect(allPosts.docs).toHaveLength(0)
})
it('ensure deleteMany is done in single db query while respecting where query', async () => {
const doc1 = await payload.create({
collection: 'posts',
data: {
title: 'Some title',
number: 5,
},
})
await payload.create({
collection: 'posts',
data: {
title: 'Some title 2',
number: 5,
},
})
await payload.create({
collection: 'posts',
data: {
title: 'Some title 2',
number: 5,
},
})
// Count every console log
const consoleCount = jest.spyOn(console, 'log').mockImplementation(() => {})
await payload.db.deleteMany({
collection: 'posts',
where: {
title: { equals: 'Some title 2' },
},
})
expect(consoleCount).toHaveBeenCalledTimes(1)
consoleCount.mockRestore()
const allPosts = await payload.find({
collection: 'posts',
})
expect(allPosts.docs).toHaveLength(1)
expect(allPosts.docs[0]?.id).toEqual(doc1.id)
})
it('ensure array update using $push is done in single db call', async () => {
const post = await payload.create({
collection: 'posts',
data: {
arrayWithIDs: [
{
text: 'some text',
},
],
title: 'post',
},
})
const consoleCount = jest.spyOn(console, 'log').mockImplementation(() => {})
await payload.db.updateOne({
data: {
// Ensure db adapter does not automatically set updatedAt - one less db call
updatedAt: null,
arrayWithIDs: {
$push: {
text: 'some text 2',
id: new mongoose.Types.ObjectId().toHexString(),
},
},
},
collection: 'posts',
id: post.id,
returning: false,
})
// 1 Update:
// 1. (updatedAt for posts row.) - skipped because we explicitly set updatedAt to null
// 2. arrayWithIDs.$push for posts row
expect(consoleCount).toHaveBeenCalledTimes(1)
consoleCount.mockRestore()
const updatedPost = (await payload.db.findOne({
collection: 'posts',
where: { id: { equals: post.id } },
})) as unknown as Post
expect(updatedPost.title).toBe('post')
expect(updatedPost.arrayWithIDs).toHaveLength(2)
expect(updatedPost.arrayWithIDs?.[0]?.text).toBe('some text')
expect(updatedPost.arrayWithIDs?.[1]?.text).toBe('some text 2')
})
})