fix: ensure updates to createdAt and updatedAt are respected (#13335)

Previously, when manually setting `createdAt` or `updatedAt` in a
`payload.db.*` or `payload.*` operation, the value may have been
ignored. In some cases it was _impossible_ to change the `updatedAt`
value, even when using direct db adapter calls. On top of that, this
behavior sometimes differed between db adapters. For example, mongodb
did accept `updatedAt` when calling `payload.db.updateVersion` -
postgres ignored it.

This PR changes this behavior to consistently respect `createdAt` and
`updatedAt` values for `payload.db.*` operations.

For `payload.*` operations, this also works with the following
exception:
- update operations do no respect `updatedAt`, as updates are commonly
performed by spreading the old data, e.g. `payload.update({ data:
{...oldData} })` - in these cases, we usually still want the `updatedAt`
to be updated. If you need to get around this, you can use the
`payload.db.updateOne` operation instead.

---
- To see the specific tasks where the Asana app for GitHub is being
used, see below:
  - https://app.asana.com/0/0/1210919646303994
This commit is contained in:
Alessio Gravili
2025-08-22 05:41:07 -07:00
committed by GitHub
parent 3408d7bdcd
commit 5c16443431
30 changed files with 437 additions and 16 deletions

View File

@@ -36,6 +36,16 @@ export const getConfig: () => Partial<Config> = () => ({
},
},
collections: [
{
slug: 'noTimeStamps',
timestamps: false,
fields: [
{
type: 'text',
name: 'title',
},
],
},
{
slug: 'categories',
versions: { drafts: true },

View File

@@ -49,6 +49,8 @@ const collection = postsSlug
const title = 'title'
process.env.PAYLOAD_CONFIG_PATH = path.join(dirname, 'config.ts')
const itMongo = process.env.PAYLOAD_DATABASE?.startsWith('mongodb') ? it : it.skip
describe('database', () => {
beforeAll(async () => {
process.env.SEED_IN_CONFIG_ONINIT = 'false' // Makes it so the payload config onInit seed is not run. Otherwise, the seed would be run unnecessarily twice for the initial test run - once for beforeEach and once for onInit
@@ -224,6 +226,12 @@ describe('database', () => {
const createdAtDate = new Date(result.createdAt)
expect(createdAtDate.getMilliseconds()).toBeDefined()
// Cleanup, as this test suite does not use clearAndSeedEverything
await payload.db.deleteMany({
collection: postsSlug,
where: {},
})
})
it('should allow createdAt to be set in create', async () => {
@@ -243,9 +251,15 @@ describe('database', () => {
expect(result.createdAt).toStrictEqual(createdAt)
expect(doc.createdAt).toStrictEqual(createdAt)
// Cleanup, as this test suite does not use clearAndSeedEverything
await payload.db.deleteMany({
collection: postsSlug,
where: {},
})
})
it('updatedAt cannot be set in create', async () => {
it('should allow updatedAt to be set in create', async () => {
const updatedAt = new Date('2022-01-01T00:00:00.000Z').toISOString()
const result = await payload.create({
collection: postsSlug,
@@ -255,8 +269,302 @@ describe('database', () => {
},
})
expect(result.updatedAt).not.toStrictEqual(updatedAt)
expect(result.updatedAt).toStrictEqual(updatedAt)
// Cleanup, as this test suite does not use clearAndSeedEverything
await payload.db.deleteMany({
collection: postsSlug,
where: {},
})
})
it('should allow createdAt to be set in update', async () => {
const post = await payload.create({
collection: postsSlug,
data: {
title: 'hello',
},
})
const createdAt = new Date('2021-01-01T00:00:00.000Z').toISOString()
const result: any = await payload.db.updateOne({
collection: postsSlug,
id: post.id,
data: {
createdAt,
},
})
const doc = await payload.findByID({
id: result.id,
collection: postsSlug,
})
expect(doc.createdAt).toStrictEqual(createdAt)
// Cleanup, as this test suite does not use clearAndSeedEverything
await payload.db.deleteMany({
collection: postsSlug,
where: {},
})
})
it('should allow updatedAt to be set in update', async () => {
const post = await payload.create({
collection: postsSlug,
data: {
title: 'hello',
},
})
const updatedAt = new Date('2021-01-01T00:00:00.000Z').toISOString()
const result: any = await payload.db.updateOne({
collection: postsSlug,
id: post.id,
data: {
updatedAt,
},
})
const doc = await payload.findByID({
id: result.id,
collection: postsSlug,
})
expect(doc.updatedAt).toStrictEqual(updatedAt)
// Cleanup, as this test suite does not use clearAndSeedEverything
await payload.db.deleteMany({
collection: postsSlug,
where: {},
})
})
it('should allow createdAt to be set in updateVersion', async () => {
const category = await payload.create({
collection: 'categories',
data: {
title: 'hello',
},
})
await payload.update({
collection: 'categories',
id: category.id,
data: {
title: 'hello2',
},
})
const versions = await payload.findVersions({
collection: 'categories',
depth: 0,
sort: '-createdAt',
})
const createdAt = new Date('2021-01-01T00:00:00.000Z').toISOString()
for (const version of versions.docs) {
await payload.db.updateVersion({
id: version.id,
collection: 'categories',
versionData: {
...version.version,
createdAt,
},
})
}
const updatedVersions = await payload.findVersions({
collection: 'categories',
depth: 0,
sort: '-createdAt',
})
expect(updatedVersions.docs).toHaveLength(2)
for (const version of updatedVersions.docs) {
expect(version.createdAt).toStrictEqual(createdAt)
}
// Cleanup, as this test suite does not use clearAndSeedEverything
await payload.db.deleteMany({
collection: 'categories',
where: {},
})
await payload.db.deleteVersions({
collection: 'categories',
where: {},
})
})
it('should allow updatedAt to be set in updateVersion', async () => {
const category = await payload.create({
collection: 'categories',
data: {
title: 'hello',
},
})
await payload.update({
collection: 'categories',
id: category.id,
data: {
title: 'hello2',
},
})
const versions = await payload.findVersions({
collection: 'categories',
depth: 0,
sort: '-createdAt',
})
const updatedAt = new Date('2021-01-01T00:00:00.000Z').toISOString()
for (const version of versions.docs) {
await payload.db.updateVersion({
id: version.id,
collection: 'categories',
versionData: {
...version.version,
updatedAt,
},
})
}
const updatedVersions = await payload.findVersions({
collection: 'categories',
depth: 0,
sort: '-updatedAt',
})
expect(updatedVersions.docs).toHaveLength(2)
for (const version of updatedVersions.docs) {
expect(version.updatedAt).toStrictEqual(updatedAt)
}
// Cleanup, as this test suite does not use clearAndSeedEverything
await payload.db.deleteMany({
collection: 'categories',
where: {},
})
await payload.db.deleteVersions({
collection: 'categories',
where: {},
})
})
async function noTimestampsTestLocalAPI() {
const createdDoc: any = await payload.create({
collection: 'noTimeStamps',
data: {
title: 'hello',
},
})
expect(createdDoc.createdAt).toBeUndefined()
expect(createdDoc.updatedAt).toBeUndefined()
const updated: any = await payload.update({
collection: 'noTimeStamps',
id: createdDoc.id,
data: {
title: 'updated',
},
})
expect(updated.createdAt).toBeUndefined()
expect(updated.updatedAt).toBeUndefined()
const date = new Date('2021-01-01T00:00:00.000Z').toISOString()
const createdDocWithTimestamps: any = await payload.create({
collection: 'noTimeStamps',
data: {
title: 'hello',
createdAt: date,
updatedAt: date,
},
})
expect(createdDocWithTimestamps.createdAt).toBeUndefined()
expect(createdDocWithTimestamps.updatedAt).toBeUndefined()
const updatedDocWithTimestamps: any = await payload.update({
collection: 'noTimeStamps',
id: createdDocWithTimestamps.id,
data: {
title: 'updated',
createdAt: date,
updatedAt: date,
},
})
expect(updatedDocWithTimestamps.createdAt).toBeUndefined()
expect(updatedDocWithTimestamps.updatedAt).toBeUndefined()
}
async function noTimestampsTestDB(aa) {
const createdDoc: any = await payload.db.create({
collection: 'noTimeStamps',
data: {
title: 'hello',
},
})
expect(createdDoc.createdAt).toBeUndefined()
expect(createdDoc.updatedAt).toBeUndefined()
const updated: any = await payload.db.updateOne({
collection: 'noTimeStamps',
id: createdDoc.id,
data: {
title: 'updated',
},
})
expect(updated.createdAt).toBeUndefined()
expect(updated.updatedAt).toBeUndefined()
const date = new Date('2021-01-01T00:00:00.000Z').toISOString()
const createdDocWithTimestamps: any = await payload.db.create({
collection: 'noTimeStamps',
data: {
title: 'hello',
createdAt: date,
updatedAt: date,
},
})
expect(createdDocWithTimestamps.createdAt).toBeUndefined()
expect(createdDocWithTimestamps.updatedAt).toBeUndefined()
const updatedDocWithTimestamps: any = await payload.db.updateOne({
collection: 'noTimeStamps',
id: createdDocWithTimestamps.id,
data: {
title: 'updated',
createdAt: date,
updatedAt: date,
},
})
expect(updatedDocWithTimestamps.createdAt).toBeUndefined()
expect(updatedDocWithTimestamps.updatedAt).toBeUndefined()
}
// eslint-disable-next-line jest/expect-expect
it('ensure timestamps are not created in update or create when timestamps are disabled', async () => {
await noTimestampsTestLocalAPI()
})
// eslint-disable-next-line jest/expect-expect
it('ensure timestamps are not created in db adapter update or create when timestamps are disabled', async () => {
await noTimestampsTestDB(true)
})
itMongo(
'ensure timestamps are not created in update or create when timestamps are disabled even with allowAdditionalKeys true',
async () => {
const originalAllowAdditionalKeys = payload.db.allowAdditionalKeys
payload.db.allowAdditionalKeys = true
await noTimestampsTestLocalAPI()
payload.db.allowAdditionalKeys = originalAllowAdditionalKeys
},
)
itMongo(
'ensure timestamps are not created in db adapter update or create when timestamps are disabled even with allowAdditionalKeys true',
async () => {
const originalAllowAdditionalKeys = payload.db.allowAdditionalKeys
payload.db.allowAdditionalKeys = true
await noTimestampsTestDB()
payload.db.allowAdditionalKeys = originalAllowAdditionalKeys
},
)
})
describe('Data strictness', () => {

View File

@@ -67,6 +67,7 @@ export interface Config {
};
blocks: {};
collections: {
noTimeStamps: NoTimeStamp;
categories: Category;
simple: Simple;
'categories-custom-id': CategoriesCustomId;
@@ -94,6 +95,7 @@ export interface Config {
};
collectionsJoins: {};
collectionsSelect: {
noTimeStamps: NoTimeStampsSelect<false> | NoTimeStampsSelect<true>;
categories: CategoriesSelect<false> | CategoriesSelect<true>;
simple: SimpleSelect<false> | SimpleSelect<true>;
'categories-custom-id': CategoriesCustomIdSelect<false> | CategoriesCustomIdSelect<true>;
@@ -163,6 +165,14 @@ export interface UserAuthOperations {
password: string;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "noTimeStamps".
*/
export interface NoTimeStamp {
id: string;
title?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "categories".
@@ -617,6 +627,10 @@ export interface User {
export interface PayloadLockedDocument {
id: string;
document?:
| ({
relationTo: 'noTimeStamps';
value: string | NoTimeStamp;
} | null)
| ({
relationTo: 'categories';
value: string | Category;
@@ -743,6 +757,13 @@ export interface PayloadMigration {
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "noTimeStamps_select".
*/
export interface NoTimeStampsSelect<T extends boolean = true> {
title?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "categories_select".