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

@@ -17,10 +17,16 @@ export const create: Create = async function create(
const options: CreateOptions = {
session: await getSession(this, req),
// Timestamps are manually added by the write transform
timestamps: false,
}
let doc
if (!data.createdAt) {
data.createdAt = new Date().toISOString()
}
transform({
adapter: this,
data,

View File

@@ -14,6 +14,10 @@ export const createGlobal: CreateGlobal = async function createGlobal(
) {
const { globalConfig, Model } = getGlobal({ adapter: this, globalSlug })
if (!data.createdAt) {
;(data as any).createdAt = new Date().toISOString()
}
transform({
adapter: this,
data,
@@ -24,6 +28,8 @@ export const createGlobal: CreateGlobal = async function createGlobal(
const options: CreateOptions = {
session: await getSession(this, req),
// Timestamps are manually added by the write transform
timestamps: false,
}
let [result] = (await Model.create([data], options)) as any

View File

@@ -25,6 +25,8 @@ export const createGlobalVersion: CreateGlobalVersion = async function createGlo
const options = {
session: await getSession(this, req),
// Timestamps are manually added by the write transform
timestamps: false,
}
const data = {
@@ -37,6 +39,9 @@ export const createGlobalVersion: CreateGlobalVersion = async function createGlo
updatedAt,
version: versionData,
}
if (!data.createdAt) {
data.createdAt = new Date().toISOString()
}
const fields = buildVersionGlobalFields(this.payload.config, globalConfig)

View File

@@ -29,6 +29,8 @@ export const createVersion: CreateVersion = async function createVersion(
const options = {
session: await getSession(this, req),
// Timestamps are manually added by the write transform
timestamps: false,
}
const data = {
@@ -41,6 +43,9 @@ export const createVersion: CreateVersion = async function createVersion(
updatedAt,
version: versionData,
}
if (!data.createdAt) {
data.createdAt = new Date().toISOString()
}
const fields = buildVersionCollectionFields(this.payload.config, collectionConfig)

View File

@@ -63,7 +63,10 @@ const migrateModelWithBatching = async ({
},
},
})),
{ session },
{
session, // Timestamps are manually added by the write transform
timestamps: false,
},
)
skip += batchSize

View File

@@ -26,6 +26,8 @@ export const updateGlobal: UpdateGlobal = async function updateGlobal(
select,
}),
session: await getSession(this, req),
// Timestamps are manually added by the write transform
timestamps: false,
}
transform({ adapter: this, data, fields, globalSlug, operation: 'write' })

View File

@@ -39,6 +39,8 @@ export async function updateGlobalVersion<T extends TypeWithID>(
select,
}),
session: await getSession(this, req),
// Timestamps are manually added by the write transform
timestamps: false,
}
const query = await buildQuery({

View File

@@ -36,6 +36,8 @@ export const updateJobs: UpdateJobs = async function updateMany(
lean: true,
new: true,
session: await getSession(this, req),
// Timestamps are manually added by the write transform
timestamps: false,
}
let query = await buildQuery({

View File

@@ -58,6 +58,8 @@ export const updateMany: UpdateMany = async function updateMany(
select,
}),
session: await getSession(this, req),
// Timestamps are manually added by the write transform
timestamps: false,
}
let query = await buildQuery({

View File

@@ -38,6 +38,8 @@ export const updateOne: UpdateOne = async function updateOne(
select,
}),
session: await getSession(this, req),
// Timestamps are manually added by the write transform
timestamps: false,
}
const query = await buildQuery({

View File

@@ -45,6 +45,8 @@ export const updateVersion: UpdateVersion = async function updateVersion(
select,
}),
session: await getSession(this, req),
// Timestamps are manually added by the write transform
timestamps: false,
}
const query = await buildQuery({

View File

@@ -395,6 +395,10 @@ describe('transform', () => {
data,
fields: config.collections[0].fields,
})
if ('updatedAt' in data) {
delete data.updatedAt
}
const flattenValuesAfter = Object.values(flattenRelationshipValues(data))
flattenValuesAfter.forEach((value, i) => {

View File

@@ -548,4 +548,10 @@ export const transform = ({
parentIsLocalized,
ref: data,
})
if (operation === 'write') {
if (!data.updatedAt) {
data.updatedAt = new Date().toISOString()
}
}
}

View File

@@ -546,6 +546,19 @@ export const traverseFields = ({
valuesToTransform.forEach(({ localeKey, ref, value }) => {
let formattedValue = value
if (field.type === 'date') {
if (fieldName === 'updatedAt' && !formattedValue) {
// let the db handle this
formattedValue = new Date().toISOString()
} else {
if (typeof value === 'number' && !Number.isNaN(value)) {
formattedValue = new Date(value).toISOString()
} else if (value instanceof Date) {
formattedValue = value.toISOString()
}
}
}
if (typeof value !== 'undefined') {
if (value && field.type === 'point' && adapter.name !== 'sqlite') {
formattedValue = sql`ST_GeomFromGeoJSON(${JSON.stringify(value)})`
@@ -570,19 +583,6 @@ export const traverseFields = ({
formattedValue = sql.raw(`${columnName} + ${value.$inc}`)
}
if (field.type === 'date') {
if (typeof value === 'number' && !Number.isNaN(value)) {
formattedValue = new Date(value).toISOString()
} else if (value instanceof Date) {
formattedValue = value.toISOString()
}
}
}
if (field.type === 'date' && fieldName === 'updatedAt') {
// let the db handle this
formattedValue = new Date().toISOString()
}
if (typeof formattedValue !== 'undefined') {

View File

@@ -42,6 +42,10 @@ export const upsertRow = async <T extends Record<string, unknown> | TypeWithID>(
upsertTarget,
where,
}: Args): Promise<T> => {
if (operation === 'create' && !data.createdAt) {
data.createdAt = new Date().toISOString()
}
let insertedRow: Record<string, unknown> = { id }
if (id && shouldUseOptimizedUpsertRow({ data, fields })) {
const { row } = transformForWrite({

View File

@@ -73,6 +73,9 @@ export const logoutOperation = async (incomingArgs: Arguments): Promise<boolean>
userWithSessions.sessions = sessionsAfterLogout
}
// Ensure updatedAt date is always updated
;(userWithSessions as any).updatedAt = new Date().toISOString()
await req.payload.db.updateOne({
id: user.id,
collection: collectionConfig.slug,

View File

@@ -92,6 +92,9 @@ export const refreshOperation = async (incomingArgs: Arguments): Promise<Result>
const tokenExpInMs = collectionConfig.auth.tokenExpiration * 1000
existingSession.expiresAt = new Date(now.getTime() + tokenExpInMs)
// Ensure updatedAt date is always updated
user.updatedAt = new Date().toISOString()
await req.payload.db.updateOne({
id: user.id,
collection: collectionConfig.slug,

View File

@@ -131,6 +131,9 @@ export const resetPasswordOperation = async <TSlug extends CollectionSlug>(
// Update new password
// /////////////////////////////////////
// Ensure updatedAt date is always updated
user.updatedAt = new Date().toISOString()
const doc = await payload.db.updateOne({
id: user.id,
collection: collectionConfig.slug,

View File

@@ -46,6 +46,9 @@ export const verifyEmailOperation = async (args: Args): Promise<boolean> => {
throw new APIError('Verification token is invalid.', httpStatus.FORBIDDEN)
}
// Ensure updatedAt date is always updated
user.updatedAt = new Date().toISOString()
await req.payload.db.updateOne({
id: user.id,
collection: collection.config.slug,

View File

@@ -49,6 +49,9 @@ export const addSessionToUser = async ({
user.sessions.push(session)
}
// Ensure updatedAt date is always updated
user.updatedAt = new Date().toISOString()
await payload.db.updateOne({
id: user.id,
collection: collectionConfig.slug,

View File

@@ -142,6 +142,9 @@ export const incrementLoginAttempts = async ({
user.sessions = currentUser.sessions
// Ensure updatedAt date is always updated
user.updatedAt = new Date().toISOString()
await payload.db.updateOne({
id: user.id,
collection: collection.slug,

View File

@@ -258,6 +258,8 @@ export const restoreVersionOperation = async <TData extends TypeWithID = any>(
select: incomingSelect,
})
// Ensure updatedAt date is always updated
result.updatedAt = new Date().toISOString()
result = await req.payload.db.updateOne({
id: parentDocID,
collection: collectionConfig.slug,

View File

@@ -293,6 +293,8 @@ export const updateDocument = async <
// /////////////////////////////////////
if (!shouldSaveDraft) {
// Ensure updatedAt date is always updated
dataToUpdate.updatedAt = new Date().toISOString()
result = await req.payload.db.updateOne({
id,
collection: collectionConfig.slug,

View File

@@ -86,6 +86,8 @@ export const restoreVersionOperation = async <T extends TypeWithVersion<T> = any
let result = rawVersion.version
if (global) {
// Ensure updatedAt date is always updated
result.updatedAt = new Date().toISOString()
result = await payload.db.updateGlobal({
slug: globalConfig.slug,
data: result,

View File

@@ -256,6 +256,9 @@ export const updateOperation = async <
result.createdAt = new Date().toISOString()
}
// Ensure updatedAt date is always updated
result.updatedAt = new Date().toISOString()
if (globalExists) {
result = await payload.db.updateGlobal({
slug,

View File

@@ -40,6 +40,7 @@ export const defaultAfterSchedule: AfterScheduleFn = async ({ jobStats, queueabl
},
},
},
updatedAt: new Date().toISOString(),
} as JobStats,
req,
returning: false,

View File

@@ -83,6 +83,9 @@ export async function updateJobs({
: undefined,
}
// Ensure updatedAt date is always updated
data.updatedAt = new Date().toISOString()
const args: UpdateJobsArgs = id
? {
id,

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".