From 35d6c70c72e6516c16f96e8d79d7e8e9b56832b2 Mon Sep 17 00:00:00 2001 From: Valeri Karpov Date: Tue, 25 Nov 2025 10:50:55 -0500 Subject: [PATCH 1/8] fix(bulkWrite): pass overwriteImmutable option to castUpdate fixes Fix #15781 Backport #15782 to 7.x --- lib/helpers/model/castBulkWrite.js | 14 +++++++++---- lib/helpers/query/castUpdate.js | 4 ++-- lib/helpers/query/handleImmutable.js | 6 +++++- test/model.updateOne.test.js | 30 ++++++++++++++++++++++++++++ 4 files changed, 47 insertions(+), 7 deletions(-) diff --git a/lib/helpers/model/castBulkWrite.js b/lib/helpers/model/castBulkWrite.js index 71d9150f848..56ee9548062 100644 --- a/lib/helpers/model/castBulkWrite.js +++ b/lib/helpers/model/castBulkWrite.js @@ -69,7 +69,9 @@ module.exports = function castBulkWrite(originalModel, op, options) { if (model.schema.$timestamps != null && op['updateOne'].timestamps !== false) { const createdAt = model.schema.$timestamps.createdAt; const updatedAt = model.schema.$timestamps.updatedAt; - applyTimestampsToUpdate(now, createdAt, updatedAt, op['updateOne']['update'], {}); + applyTimestampsToUpdate(now, createdAt, updatedAt, op['updateOne']['update'], { + timestamps: op['updateOne'].timestamps + }); } if (op['updateOne'].timestamps !== false) { @@ -100,7 +102,8 @@ module.exports = function castBulkWrite(originalModel, op, options) { op['updateOne']['update'] = castUpdate(model.schema, op['updateOne']['update'], { strict: strict, overwrite: false, - upsert: op['updateOne'].upsert + upsert: op['updateOne'].upsert, + overwriteImmutable: op['updateOne'].overwriteImmutable }, model, op['updateOne']['filter']); } catch (error) { return callback(error, null); @@ -136,7 +139,9 @@ module.exports = function castBulkWrite(originalModel, op, options) { if (model.schema.$timestamps != null && op['updateMany'].timestamps !== false) { const createdAt = model.schema.$timestamps.createdAt; const updatedAt = model.schema.$timestamps.updatedAt; - applyTimestampsToUpdate(now, createdAt, updatedAt, op['updateMany']['update'], {}); + applyTimestampsToUpdate(now, createdAt, updatedAt, op['updateMany']['update'], { + timestamps: op['updateMany'].timestamps + }); } if (op['updateMany'].timestamps !== false) { applyTimestampsToChildren(now, op['updateMany']['update'], model.schema); @@ -158,7 +163,8 @@ module.exports = function castBulkWrite(originalModel, op, options) { op['updateMany']['update'] = castUpdate(model.schema, op['updateMany']['update'], { strict: strict, overwrite: false, - upsert: op['updateMany'].upsert + upsert: op['updateMany'].upsert, + overwriteImmutable: op['updateMany'].overwriteImmutable }, model, op['updateMany']['filter']); } catch (error) { return callback(error, null); diff --git a/lib/helpers/query/castUpdate.js b/lib/helpers/query/castUpdate.js index 25fbb456ea1..2e097443c48 100644 --- a/lib/helpers/query/castUpdate.js +++ b/lib/helpers/query/castUpdate.js @@ -240,7 +240,7 @@ function walkUpdatePath(schema, obj, op, options, context, filter, pref) { if (op !== '$setOnInsert' && !options.overwrite && - handleImmutable(schematype, strict, obj, key, prefix + key, context)) { + handleImmutable(schematype, strict, obj, key, prefix + key, options, context)) { continue; } @@ -335,7 +335,7 @@ function walkUpdatePath(schema, obj, op, options, context, filter, pref) { // You can use `$setOnInsert` with immutable keys if (op !== '$setOnInsert' && !options.overwrite && - handleImmutable(schematype, strict, obj, key, prefix + key, context)) { + handleImmutable(schematype, strict, obj, key, prefix + key, options, context)) { continue; } diff --git a/lib/helpers/query/handleImmutable.js b/lib/helpers/query/handleImmutable.js index 22adb3c50de..dc26adbb3e6 100644 --- a/lib/helpers/query/handleImmutable.js +++ b/lib/helpers/query/handleImmutable.js @@ -2,7 +2,7 @@ const StrictModeError = require('../../error/strict'); -module.exports = function handleImmutable(schematype, strict, obj, key, fullPath, ctx) { +module.exports = function handleImmutable(schematype, strict, obj, key, fullPath, options, ctx) { if (schematype == null || !schematype.options || !schematype.options.immutable) { return false; } @@ -15,6 +15,10 @@ module.exports = function handleImmutable(schematype, strict, obj, key, fullPath return false; } + if (options && options.overwriteImmutable) { + return false; + } + if (strict === false) { return false; } diff --git a/test/model.updateOne.test.js b/test/model.updateOne.test.js index 8eab40a6a6b..5e5954c6c44 100644 --- a/test/model.updateOne.test.js +++ b/test/model.updateOne.test.js @@ -2707,6 +2707,36 @@ describe('model: updateOne: ', function() { assert.equal(doc.age, 20); }); + it('overwriting immutable createdAt with bulkWrite (gh-15781)', async function() { + const start = new Date().valueOf(); + const schema = Schema({ + createdAt: { + type: mongoose.Schema.Types.Date, + immutable: true + }, + name: String + }, { timestamps: true }); + + const Model = db.model('Test', schema); + + await Model.create({ name: 'gh-15781' }); + let doc = await Model.collection.findOne({ name: 'gh-15781' }); + assert.ok(doc.createdAt.valueOf() >= start); + + const createdAt = new Date('2011-06-01'); + assert.ok(createdAt.valueOf() < start.valueOf()); + await Model.bulkWrite([{ + updateOne: { + filter: { _id: doc._id }, + update: { name: 'gh-15781 update', createdAt }, + overwriteImmutable: true, + timestamps: false + } + }]); + doc = await Model.collection.findOne({ name: 'gh-15781 update' }); + assert.equal(doc.createdAt.valueOf(), createdAt.valueOf()); + }); + it('updates buffers with `runValidators` successfully (gh-8580)', async function() { const Test = db.model('Test', Schema({ data: { type: Buffer, required: true } From cc306f605805eac14aa2e7f4b04fbe6105fb3e86 Mon Sep 17 00:00:00 2001 From: Hafez Date: Sun, 30 Nov 2025 01:52:08 +0100 Subject: [PATCH 2/8] fix: add overwriteImmutable in bulkWrite, port bulkwrite enriched option types to 7.x --- lib/helpers/model/castBulkWrite.js | 22 +++++- test/model.test.js | 46 +++++++++++++ test/types/models.test.ts | 41 +++++++++++- types/models.d.ts | 103 ++++++++++++++++++++++++++++- 4 files changed, 205 insertions(+), 7 deletions(-) diff --git a/lib/helpers/model/castBulkWrite.js b/lib/helpers/model/castBulkWrite.js index 56ee9548062..0120117f337 100644 --- a/lib/helpers/model/castBulkWrite.js +++ b/lib/helpers/model/castBulkWrite.js @@ -28,7 +28,7 @@ module.exports = function castBulkWrite(originalModel, op, options) { const model = decideModelByObject(originalModel, op['insertOne']['document']); const doc = new model(op['insertOne']['document']); - if (model.schema.options.timestamps && options.timestamps !== false) { + if (model.schema.options.timestamps && getTimestampsOpt(op['insertOne'], options)) { doc.initializeTimestamps(); } if (options.session != null) { @@ -190,7 +190,7 @@ module.exports = function castBulkWrite(originalModel, op, options) { // set `skipId`, otherwise we get "_id field cannot be changed" const doc = new model(op['replaceOne']['replacement'], strict, true); - if (model.schema.options.timestamps) { + if (model.schema.options.timestamps && getTimestampsOpt(op['replaceOne'], options)) { doc.initializeTimestamps(); } if (options.session != null) { @@ -279,3 +279,21 @@ function decideModelByObject(model, object) { } return model; } + +/** + * Gets timestamps option for a given operation. If the option is set within an individual + * operation, use it. Otherwise, use the global timestamps option configured in the `bulkWrite` + * options. Overall default is `true`. + * @api private + */ + +function getTimestampsOpt(opCommand, options) { + const opLevelOpt = opCommand.timestamps; + const bulkLevelOpt = options.timestamps; + if (opLevelOpt != null) { + return opLevelOpt; + } else if (bulkLevelOpt != null) { + return bulkLevelOpt; + } + return true; +} diff --git a/test/model.test.js b/test/model.test.js index 04516afa3be..d660960a154 100644 --- a/test/model.test.js +++ b/test/model.test.js @@ -5926,6 +5926,52 @@ describe('Model', function() { }); + it('bulkWrite can disable timestamps with insertOne and replaceOne (gh-15782)', async function() { + const userSchema = new Schema({ + name: String + }, { timestamps: true }); + + const User = db.model('User', userSchema); + + const user = await User.create({ name: 'Hafez' }); + + await User.bulkWrite([ + { insertOne: { document: { name: 'insertOne-test' }, timestamps: false } }, + { replaceOne: { filter: { _id: user._id }, replacement: { name: 'replaceOne-test' }, timestamps: false } } + ]); + + const insertedDoc = await User.findOne({ name: 'insertOne-test' }); + assert.strictEqual(insertedDoc.createdAt, undefined); + assert.strictEqual(insertedDoc.updatedAt, undefined); + + const replacedDoc = await User.findOne({ name: 'replaceOne-test' }); + assert.strictEqual(replacedDoc.createdAt, undefined); + assert.strictEqual(replacedDoc.updatedAt, undefined); + }); + + it('bulkWrite insertOne and replaceOne respect per-op timestamps: true when global is false (gh-15782)', async function() { + const userSchema = new Schema({ + name: String + }, { timestamps: true }); + + const User = db.model('User', userSchema); + + const user = await User.create({ name: 'Hafez' }); + + await User.bulkWrite([ + { insertOne: { document: { name: 'insertOne-test' }, timestamps: true } }, + { replaceOne: { filter: { _id: user._id }, replacement: { name: 'replaceOne-test' }, timestamps: true } } + ], { timestamps: false }); + + const insertedDoc = await User.findOne({ name: 'insertOne-test' }); + assert.ok(insertedDoc.createdAt instanceof Date); + assert.ok(insertedDoc.updatedAt instanceof Date); + + const replacedDoc = await User.findOne({ name: 'replaceOne-test' }); + assert.ok(replacedDoc.createdAt instanceof Date); + assert.ok(replacedDoc.updatedAt instanceof Date); + }); + it('bulkwrite should not change updatedAt on subdocs when timestamps set to false (gh-13611)', async function() { const postSchema = new Schema({ diff --git a/test/types/models.test.ts b/test/types/models.test.ts index 0e00f16fc74..08fc0824b9b 100644 --- a/test/types/models.test.ts +++ b/test/types/models.test.ts @@ -13,11 +13,13 @@ import mongoose, { Query, UpdateWriteOpResult, AggregateOptions, - StringSchemaDefinition + StringSchemaDefinition, + UpdateOneModel, + UpdateManyModel } from 'mongoose'; import { expectAssignable, expectError, expectType } from 'tsd'; import { AutoTypedSchemaType, autoTypedSchema } from './schema.test'; -import { UpdateOneModel, ChangeStreamInsertDocument, ObjectId } from 'mongodb'; +import { UpdateOneModel as MongoUpdateOneModel, ChangeStreamInsertDocument, ObjectId } from 'mongodb'; function rawDocSyntax(): void { interface ITest { @@ -415,7 +417,7 @@ function gh11911() { const Animal = model('Animal', animalSchema); const changes: UpdateQuery = {}; - expectAssignable({ + expectAssignable({ filter: {}, update: changes }); @@ -766,3 +768,36 @@ async function gh14003() { await TestModel.validate({ name: 'foo' }, ['name']); await TestModel.validate({ name: 'foo' }, { pathsToSkip: ['name'] }); } + +async function gh15781() { + const userSchema = new Schema({ + createdAt: { type: Date, immutable: true }, + name: String + }, { timestamps: true }); + + const User = model('User', userSchema); + + await User.bulkWrite([ + { + updateOne: { + filter: { name: 'John' }, + update: { createdAt: new Date() }, + overwriteImmutable: true, + timestamps: false + } + }, + { + updateMany: { + filter: { name: 'Jane' }, + update: { createdAt: new Date() }, + overwriteImmutable: true, + timestamps: false + } + } + ]); + + expectType({} as UpdateOneModel['timestamps']); + expectType({} as UpdateOneModel['overwriteImmutable']); + expectType({} as UpdateManyModel['timestamps']); + expectType({} as UpdateManyModel['overwriteImmutable']); +} diff --git a/types/models.d.ts b/types/models.d.ts index 7cb082bc372..7c40f30d52d 100644 --- a/types/models.d.ts +++ b/types/models.d.ts @@ -142,6 +142,105 @@ declare module 'mongoose' { interface RemoveOptions extends SessionOption, Omit {} + export type AnyBulkWriteOperation = { + insertOne: InsertOneModel; + } | { + replaceOne: ReplaceOneModel; + } | { + updateOne: UpdateOneModel; + } | { + updateMany: UpdateManyModel; + } | { + deleteOne: DeleteOneModel; + } | { + deleteMany: DeleteManyModel; + }; + + export interface InsertOneModel { + document: mongodb.OptionalId; + /** Skip validation for this operation. */ + skipValidation?: boolean; + /** When false, do not add timestamps. When true, overrides the `timestamps` option set in the `bulkWrite` options. */ + timestamps?: boolean; + } + + export interface ReplaceOneModel { + /** The filter to limit the replaced document. */ + filter: FilterQuery; + /** The document with which to replace the matched document. */ + replacement: mongodb.WithoutId; + /** Specifies a collation. */ + collation?: mongodb.CollationOptions; + /** The index to use. If specified, then the query system will only consider plans using the hinted index. */ + hint?: mongodb.Hint; + /** When true, creates a new document if no document matches the query. */ + upsert?: boolean; + /** Skip validation for this operation. */ + skipValidation?: boolean; + /** When false, do not add timestamps. When true, overrides the `timestamps` option set in the `bulkWrite` options. */ + timestamps?: boolean; + } + + export interface UpdateOneModel { + /** The filter to limit the updated documents. */ + filter: FilterQuery; + /** A document or pipeline containing update operators. */ + update: UpdateQuery | UpdateQuery[]; + /** A set of filters specifying to which array elements an update should apply. */ + arrayFilters?: AnyObject[]; + /** Specifies a collation. */ + collation?: mongodb.CollationOptions; + /** The index to use. If specified, then the query system will only consider plans using the hinted index. */ + hint?: mongodb.Hint; + /** When true, creates a new document if no document matches the query. */ + upsert?: boolean; + /** When false, do not add timestamps. When true, overrides the `timestamps` option set in the `bulkWrite` options. */ + timestamps?: boolean; + /** When true, allows updating fields that are marked as `immutable` in the schema. */ + overwriteImmutable?: boolean; + /** When false, do not set default values on insert. */ + setDefaultsOnInsert?: boolean; + } + + export interface UpdateManyModel { + /** The filter to limit the updated documents. */ + filter: FilterQuery; + /** A document or pipeline containing update operators. */ + update: UpdateQuery | UpdateQuery[]; + /** A set of filters specifying to which array elements an update should apply. */ + arrayFilters?: AnyObject[]; + /** Specifies a collation. */ + collation?: mongodb.CollationOptions; + /** The index to use. If specified, then the query system will only consider plans using the hinted index. */ + hint?: mongodb.Hint; + /** When true, creates a new document if no document matches the query. */ + upsert?: boolean; + /** When false, do not add timestamps. When true, overrides the `timestamps` option set in the `bulkWrite` options. */ + timestamps?: boolean; + /** When true, allows updating fields that are marked as `immutable` in the schema. */ + overwriteImmutable?: boolean; + /** When false, do not set default values on insert. */ + setDefaultsOnInsert?: boolean; + } + + export interface DeleteOneModel { + /** The filter to limit the deleted documents. */ + filter: FilterQuery; + /** Specifies a collation. */ + collation?: mongodb.CollationOptions; + /** The index to use. If specified, then the query system will only consider plans using the hinted index. */ + hint?: mongodb.Hint; + } + + export interface DeleteManyModel { + /** The filter to limit the deleted documents. */ + filter: FilterQuery; + /** Specifies a collation. */ + collation?: mongodb.CollationOptions; + /** The index to use. If specified, then the query system will only consider plans using the hinted index. */ + hint?: mongodb.Hint; + } + const Model: Model; /** @@ -185,11 +284,11 @@ declare module 'mongoose' { * round trip to the MongoDB server. */ bulkWrite( - writes: Array>, + writes: Array>, options: mongodb.BulkWriteOptions & MongooseBulkWriteOptions & { ordered: false } ): Promise; bulkWrite( - writes: Array>, + writes: Array>, options?: mongodb.BulkWriteOptions & MongooseBulkWriteOptions ): Promise; From a5e89da341b612f1f0c110a7595be77e1b1d4b48 Mon Sep 17 00:00:00 2001 From: Hafez Date: Tue, 2 Dec 2025 01:14:10 +0100 Subject: [PATCH 3/8] fix: address PR feedback --- lib/helpers/model/castBulkWrite.js | 21 +----- types/models.d.ts | 109 +++++++---------------------- 2 files changed, 27 insertions(+), 103 deletions(-) diff --git a/lib/helpers/model/castBulkWrite.js b/lib/helpers/model/castBulkWrite.js index 0120117f337..7a503fcee81 100644 --- a/lib/helpers/model/castBulkWrite.js +++ b/lib/helpers/model/castBulkWrite.js @@ -28,7 +28,7 @@ module.exports = function castBulkWrite(originalModel, op, options) { const model = decideModelByObject(originalModel, op['insertOne']['document']); const doc = new model(op['insertOne']['document']); - if (model.schema.options.timestamps && getTimestampsOpt(op['insertOne'], options)) { + if (model.schema.options.timestamps && (op['insertOne'].timestamps ?? options.timestamps ?? true)) { doc.initializeTimestamps(); } if (options.session != null) { @@ -190,7 +190,7 @@ module.exports = function castBulkWrite(originalModel, op, options) { // set `skipId`, otherwise we get "_id field cannot be changed" const doc = new model(op['replaceOne']['replacement'], strict, true); - if (model.schema.options.timestamps && getTimestampsOpt(op['replaceOne'], options)) { + if (model.schema.options.timestamps && (op['replaceOne'].timestamps ?? options.timestamps ?? true)) { doc.initializeTimestamps(); } if (options.session != null) { @@ -280,20 +280,3 @@ function decideModelByObject(model, object) { return model; } -/** - * Gets timestamps option for a given operation. If the option is set within an individual - * operation, use it. Otherwise, use the global timestamps option configured in the `bulkWrite` - * options. Overall default is `true`. - * @api private - */ - -function getTimestampsOpt(opCommand, options) { - const opLevelOpt = opCommand.timestamps; - const bulkLevelOpt = options.timestamps; - if (opLevelOpt != null) { - return opLevelOpt; - } else if (bulkLevelOpt != null) { - return bulkLevelOpt; - } - return true; -} diff --git a/types/models.d.ts b/types/models.d.ts index 7c40f30d52d..90e7e1456e7 100644 --- a/types/models.d.ts +++ b/types/models.d.ts @@ -142,104 +142,45 @@ declare module 'mongoose' { interface RemoveOptions extends SessionOption, Omit {} - export type AnyBulkWriteOperation = { - insertOne: InsertOneModel; - } | { - replaceOne: ReplaceOneModel; - } | { - updateOne: UpdateOneModel; - } | { - updateMany: UpdateManyModel; - } | { - deleteOne: DeleteOneModel; - } | { - deleteMany: DeleteManyModel; - }; - - export interface InsertOneModel { - document: mongodb.OptionalId; - /** Skip validation for this operation. */ - skipValidation?: boolean; - /** When false, do not add timestamps. When true, overrides the `timestamps` option set in the `bulkWrite` options. */ - timestamps?: boolean; - } - - export interface ReplaceOneModel { - /** The filter to limit the replaced document. */ - filter: FilterQuery; - /** The document with which to replace the matched document. */ - replacement: mongodb.WithoutId; - /** Specifies a collation. */ - collation?: mongodb.CollationOptions; - /** The index to use. If specified, then the query system will only consider plans using the hinted index. */ - hint?: mongodb.Hint; - /** When true, creates a new document if no document matches the query. */ - upsert?: boolean; + interface MongooseBulkWritePerOperationOptions { /** Skip validation for this operation. */ skipValidation?: boolean; /** When false, do not add timestamps. When true, overrides the `timestamps` option set in the `bulkWrite` options. */ timestamps?: boolean; } - export interface UpdateOneModel { - /** The filter to limit the updated documents. */ - filter: FilterQuery; - /** A document or pipeline containing update operators. */ - update: UpdateQuery | UpdateQuery[]; - /** A set of filters specifying to which array elements an update should apply. */ - arrayFilters?: AnyObject[]; - /** Specifies a collation. */ - collation?: mongodb.CollationOptions; - /** The index to use. If specified, then the query system will only consider plans using the hinted index. */ - hint?: mongodb.Hint; - /** When true, creates a new document if no document matches the query. */ - upsert?: boolean; - /** When false, do not add timestamps. When true, overrides the `timestamps` option set in the `bulkWrite` options. */ - timestamps?: boolean; + interface MongooseBulkUpdatePerOperationOptions extends MongooseBulkWritePerOperationOptions { /** When true, allows updating fields that are marked as `immutable` in the schema. */ overwriteImmutable?: boolean; /** When false, do not set default values on insert. */ setDefaultsOnInsert?: boolean; } - export interface UpdateManyModel { - /** The filter to limit the updated documents. */ - filter: FilterQuery; - /** A document or pipeline containing update operators. */ - update: UpdateQuery | UpdateQuery[]; - /** A set of filters specifying to which array elements an update should apply. */ - arrayFilters?: AnyObject[]; - /** Specifies a collation. */ - collation?: mongodb.CollationOptions; - /** The index to use. If specified, then the query system will only consider plans using the hinted index. */ - hint?: mongodb.Hint; - /** When true, creates a new document if no document matches the query. */ - upsert?: boolean; - /** When false, do not add timestamps. When true, overrides the `timestamps` option set in the `bulkWrite` options. */ - timestamps?: boolean; - /** When true, allows updating fields that are marked as `immutable` in the schema. */ - overwriteImmutable?: boolean; - /** When false, do not set default values on insert. */ - setDefaultsOnInsert?: boolean; - } + export type InsertOneModel = + mongodb.InsertOneModel & MongooseBulkWritePerOperationOptions; - export interface DeleteOneModel { - /** The filter to limit the deleted documents. */ - filter: FilterQuery; - /** Specifies a collation. */ - collation?: mongodb.CollationOptions; - /** The index to use. If specified, then the query system will only consider plans using the hinted index. */ - hint?: mongodb.Hint; - } + export type ReplaceOneModel = + mongodb.ReplaceOneModel & MongooseBulkWritePerOperationOptions; - export interface DeleteManyModel { - /** The filter to limit the deleted documents. */ - filter: FilterQuery; - /** Specifies a collation. */ - collation?: mongodb.CollationOptions; - /** The index to use. If specified, then the query system will only consider plans using the hinted index. */ - hint?: mongodb.Hint; - } + export type UpdateOneModel = + mongodb.UpdateOneModel & MongooseBulkUpdatePerOperationOptions; + + export type UpdateManyModel = + mongodb.UpdateManyModel & MongooseBulkUpdatePerOperationOptions; + + export type DeleteOneModel = + mongodb.DeleteOneModel; + + export type DeleteManyModel = + mongodb.DeleteManyModel; + + export type AnyBulkWriteOperation = + | { insertOne: InsertOneModel } + | { replaceOne: ReplaceOneModel } + | { updateOne: UpdateOneModel } + | { updateMany: UpdateManyModel } + | { deleteOne: DeleteOneModel } + | { deleteMany: DeleteManyModel }; const Model: Model; From ff6831ef1eb0d87bc3609af8137eade5875288aa Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 3 Dec 2025 03:55:46 +0100 Subject: [PATCH 4/8] test(model): add more tests for overwriteImmutable with bulkWrite --- test/model.updateOne.test.js | 112 +++++++++++++++++++++++++++-------- 1 file changed, 87 insertions(+), 25 deletions(-) diff --git a/test/model.updateOne.test.js b/test/model.updateOne.test.js index 5e5954c6c44..b6ad030a218 100644 --- a/test/model.updateOne.test.js +++ b/test/model.updateOne.test.js @@ -2707,34 +2707,96 @@ describe('model: updateOne: ', function() { assert.equal(doc.age, 20); }); - it('overwriting immutable createdAt with bulkWrite (gh-15781)', async function() { - const start = new Date().valueOf(); - const schema = Schema({ - createdAt: { - type: mongoose.Schema.Types.Date, - immutable: true - }, - name: String - }, { timestamps: true }); + describe.only('bulkWrite overwriteImmutable option (gh-15781)', function() { + it('updateOne can update immutable field with overwriteImmutable: true', async function() { + // Arrange + const { User } = createTestContext(); + const user = await User.create({ name: 'John', ssn: '123-45-6789' }); + const customCreatedAt = new Date('2020-01-01'); + + // Act + await User.bulkWrite([{ + updateOne: { + filter: { _id: user._id }, + update: { createdAt: customCreatedAt, ssn: '999-99-9999' }, + overwriteImmutable: true + } + }]); + + // Assert + const updatedUser = await User.findById(user._id); + assert.strictEqual(updatedUser.ssn, '999-99-9999'); + assert.strictEqual(updatedUser.createdAt.valueOf(), customCreatedAt.valueOf()); + }); + + it('updateMany can update immutable field with overwriteImmutable: true', async function() { + // Arrange + const { User } = createTestContext(); + const user = await User.create({ name: 'Alice', ssn: '111-11-1111' }); + const customCreatedAt = new Date('2020-01-01'); + + // Act + await User.bulkWrite([{ + updateMany: { + filter: { _id: user._id }, + update: { createdAt: customCreatedAt, ssn: '000-00-0000' }, + overwriteImmutable: true + } + }]); - const Model = db.model('Test', schema); + // Assert + const updatedUser = await User.findById(user._id); + assert.strictEqual(updatedUser.ssn, '000-00-0000'); + assert.strictEqual(updatedUser.createdAt.valueOf(), customCreatedAt.valueOf()); + }); - await Model.create({ name: 'gh-15781' }); - let doc = await Model.collection.findOne({ name: 'gh-15781' }); - assert.ok(doc.createdAt.valueOf() >= start); + for (const timestamps of [true, false, null, undefined]) + it(`overwriting immutable createdAt with bulkWrite (gh-15781) when \`timestamps\` is \`${timestamps}\``, async function() { + // Arrange + const schema = Schema({ name: String }, { timestamps: true }); - const createdAt = new Date('2011-06-01'); - assert.ok(createdAt.valueOf() < start.valueOf()); - await Model.bulkWrite([{ - updateOne: { - filter: { _id: doc._id }, - update: { name: 'gh-15781 update', createdAt }, - overwriteImmutable: true, - timestamps: false - } - }]); - doc = await Model.collection.findOne({ name: 'gh-15781 update' }); - assert.equal(doc.createdAt.valueOf(), createdAt.valueOf()); + const Model = db.model('Test', schema); + + const doc1 = await Model.create({ name: 'gh-15781-1' }); + const doc2 = await Model.create({ name: 'gh-15781-2' }); + + // Act + const createdAt = new Date('2011-06-01'); + + await Model.bulkWrite([ + { + updateOne: { + filter: { _id: doc1._id }, + update: { createdAt }, + overwriteImmutable: true, + timestamps + } + }, + { + updateMany: { + filter: { _id: doc2._id }, + update: { createdAt }, + overwriteImmutable: true, + timestamps + } + } + ]); + + // Assert + const updatesDocs = await Model.find({ _id: { $in: [doc1._id, doc2._id] } }); + + assert.equal(updatesDocs[0].createdAt.valueOf(), createdAt.valueOf()); + assert.equal(updatesDocs[1].createdAt.valueOf(), createdAt.valueOf()); + }); + + function createTestContext() { + const userSchema = new Schema({ + name: String, + ssn: { type: String, immutable: true } + }, { timestamps: true }); + const User = db.model('User', userSchema); + return { User }; + } }); it('updates buffers with `runValidators` successfully (gh-8580)', async function() { From 706b9e46b4f349f01037ac2d320061951647d224 Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 3 Dec 2025 03:56:24 +0100 Subject: [PATCH 5/8] fix(model): update createdAt when overwriteImmutable is true regardless of `timestamps` option --- lib/helpers/model/castBulkWrite.js | 6 +- lib/helpers/update/applyTimestampsToUpdate.js | 61 +++++++++++-------- 2 files changed, 41 insertions(+), 26 deletions(-) diff --git a/lib/helpers/model/castBulkWrite.js b/lib/helpers/model/castBulkWrite.js index 7a503fcee81..49b0ded06df 100644 --- a/lib/helpers/model/castBulkWrite.js +++ b/lib/helpers/model/castBulkWrite.js @@ -70,7 +70,8 @@ module.exports = function castBulkWrite(originalModel, op, options) { const createdAt = model.schema.$timestamps.createdAt; const updatedAt = model.schema.$timestamps.updatedAt; applyTimestampsToUpdate(now, createdAt, updatedAt, op['updateOne']['update'], { - timestamps: op['updateOne'].timestamps + timestamps: op['updateOne'].timestamps, + overwriteImmutable: op['updateOne'].overwriteImmutable }); } @@ -140,7 +141,8 @@ module.exports = function castBulkWrite(originalModel, op, options) { const createdAt = model.schema.$timestamps.createdAt; const updatedAt = model.schema.$timestamps.updatedAt; applyTimestampsToUpdate(now, createdAt, updatedAt, op['updateMany']['update'], { - timestamps: op['updateMany'].timestamps + timestamps: op['updateMany'].timestamps, + overwriteImmutable: op['updateMany'].overwriteImmutable }); } if (op['updateMany'].timestamps !== false) { diff --git a/lib/helpers/update/applyTimestampsToUpdate.js b/lib/helpers/update/applyTimestampsToUpdate.js index b48febafb69..ae83b403117 100644 --- a/lib/helpers/update/applyTimestampsToUpdate.js +++ b/lib/helpers/update/applyTimestampsToUpdate.js @@ -81,33 +81,46 @@ function applyTimestampsToUpdate(now, createdAt, updatedAt, currentUpdate, optio } if (!skipCreatedAt && createdAt) { - if (currentUpdate[createdAt]) { - delete currentUpdate[createdAt]; - } - if (currentUpdate.$set && currentUpdate.$set[createdAt]) { - delete currentUpdate.$set[createdAt]; - } - let timestampSet = false; - if (createdAt.indexOf('.') !== -1) { - const pieces = createdAt.split('.'); - for (let i = 1; i < pieces.length; ++i) { - const remnant = pieces.slice(-i).join('.'); - const start = pieces.slice(0, -i).join('.'); - if (currentUpdate[start] != null) { - currentUpdate[start][remnant] = now; - timestampSet = true; - break; - } else if (currentUpdate.$set && currentUpdate.$set[start]) { - currentUpdate.$set[start][remnant] = now; - timestampSet = true; - break; + const overwriteImmutable = get(options, 'overwriteImmutable', false); + const hasUserCreatedAt = currentUpdate[createdAt] != null || currentUpdate?.$set[createdAt] != null; + + // If overwriteImmutable is true and user provided createdAt, keep their value + if (overwriteImmutable && hasUserCreatedAt) { + // Move createdAt from top-level to $set if needed + if (currentUpdate[createdAt] != null) { + updates.$set[createdAt] = currentUpdate[createdAt]; + delete currentUpdate[createdAt]; + } + // User's value is already in $set, nothing more to do + } else { + if (currentUpdate[createdAt]) { + delete currentUpdate[createdAt]; + } + if (currentUpdate.$set && currentUpdate.$set[createdAt]) { + delete currentUpdate.$set[createdAt]; + } + let timestampSet = false; + if (createdAt.indexOf('.') !== -1) { + const pieces = createdAt.split('.'); + for (let i = 1; i < pieces.length; ++i) { + const remnant = pieces.slice(-i).join('.'); + const start = pieces.slice(0, -i).join('.'); + if (currentUpdate[start] != null) { + currentUpdate[start][remnant] = now; + timestampSet = true; + break; + } else if (currentUpdate.$set && currentUpdate.$set[start]) { + currentUpdate.$set[start][remnant] = now; + timestampSet = true; + break; + } } } - } - if (!timestampSet) { - updates.$setOnInsert = updates.$setOnInsert || {}; - updates.$setOnInsert[createdAt] = now; + if (!timestampSet) { + updates.$setOnInsert = updates.$setOnInsert || {}; + updates.$setOnInsert[createdAt] = now; + } } } From 35ee075a2adafcd4456945b7ff4c0cdaa6a82810 Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 3 Dec 2025 03:58:47 +0100 Subject: [PATCH 6/8] test: remove .only --- test/model.updateOne.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/model.updateOne.test.js b/test/model.updateOne.test.js index b6ad030a218..b1f78d9baaf 100644 --- a/test/model.updateOne.test.js +++ b/test/model.updateOne.test.js @@ -2707,7 +2707,7 @@ describe('model: updateOne: ', function() { assert.equal(doc.age, 20); }); - describe.only('bulkWrite overwriteImmutable option (gh-15781)', function() { + describe('bulkWrite overwriteImmutable option (gh-15781)', function() { it('updateOne can update immutable field with overwriteImmutable: true', async function() { // Arrange const { User } = createTestContext(); From 7cab3ca56e15fdebe23b69e04fc1fe044827576d Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 3 Dec 2025 05:17:20 +0100 Subject: [PATCH 7/8] test(model): add test to assert inability to update immutable fields without overwriteImmutable: true --- test/model.updateOne.test.js | 42 +++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/test/model.updateOne.test.js b/test/model.updateOne.test.js index b1f78d9baaf..5a3ab96ff3a 100644 --- a/test/model.updateOne.test.js +++ b/test/model.updateOne.test.js @@ -2750,7 +2750,7 @@ describe('model: updateOne: ', function() { assert.strictEqual(updatedUser.createdAt.valueOf(), customCreatedAt.valueOf()); }); - for (const timestamps of [true, false, null, undefined]) + for (const timestamps of [true, false, null, undefined]) { it(`overwriting immutable createdAt with bulkWrite (gh-15781) when \`timestamps\` is \`${timestamps}\``, async function() { // Arrange const schema = Schema({ name: String }, { timestamps: true }); @@ -2788,6 +2788,46 @@ describe('model: updateOne: ', function() { assert.equal(updatesDocs[0].createdAt.valueOf(), createdAt.valueOf()); assert.equal(updatesDocs[1].createdAt.valueOf(), createdAt.valueOf()); }); + } + + it('can not update immutable fields without overwriteImmutable: true', async function() { + // Arrange + const { User } = createTestContext(); + const users = await User.create([ + { name: 'Bob', ssn: '222-22-2222' }, + { name: 'Eve', ssn: '333-33-3333' } + ]); + const newCreatedAt = new Date('2020-01-01'); + + // Act + await User.bulkWrite([ + { + updateOne: { + filter: { _id: users[0]._id }, + update: { ssn: '888-88-8888', createdAt: newCreatedAt } + } + + }, + { + updateMany: { + filter: { _id: users[1]._id }, + update: { ssn: '777-77-7777', createdAt: newCreatedAt } + } + } + ]); + + + // Assert + const [updatedUser1, updatedUser2] = await Promise.all([ + User.findById(users[0]._id), + User.findById(users[1]._id) + ]); + assert.strictEqual(updatedUser1.ssn, '222-22-2222'); + assert.notStrictEqual(updatedUser1.createdAt.valueOf(), newCreatedAt.valueOf()); + + assert.strictEqual(updatedUser2.ssn, '333-33-3333'); + assert.notStrictEqual(updatedUser2.createdAt.valueOf(), newCreatedAt.valueOf()); + }); function createTestContext() { const userSchema = new Schema({ From d775807e3a1f43490c38b4f6dc40636191c5f29f Mon Sep 17 00:00:00 2001 From: Hafez Date: Wed, 3 Dec 2025 20:36:32 +0100 Subject: [PATCH 8/8] test(model): make tests more strict by asserting `timestamps` does not matter when updating `createdAt` with `overwriteImmutable: true` --- test/model.updateOne.test.js | 67 ++++++++++++++++++------------------ 1 file changed, 34 insertions(+), 33 deletions(-) diff --git a/test/model.updateOne.test.js b/test/model.updateOne.test.js index 5a3ab96ff3a..3d1ca408ac3 100644 --- a/test/model.updateOne.test.js +++ b/test/model.updateOne.test.js @@ -2788,46 +2788,47 @@ describe('model: updateOne: ', function() { assert.equal(updatesDocs[0].createdAt.valueOf(), createdAt.valueOf()); assert.equal(updatesDocs[1].createdAt.valueOf(), createdAt.valueOf()); }); - } - it('can not update immutable fields without overwriteImmutable: true', async function() { + it(`can not update immutable fields without overwriteImmutable: true and timestamps: ${timestamps}`, async function() { // Arrange - const { User } = createTestContext(); - const users = await User.create([ - { name: 'Bob', ssn: '222-22-2222' }, - { name: 'Eve', ssn: '333-33-3333' } - ]); - const newCreatedAt = new Date('2020-01-01'); - - // Act - await User.bulkWrite([ - { - updateOne: { - filter: { _id: users[0]._id }, - update: { ssn: '888-88-8888', createdAt: newCreatedAt } - } + const { User } = createTestContext(); + const users = await User.create([ + { name: 'Bob', ssn: '222-22-2222' }, + { name: 'Eve', ssn: '333-33-3333' } + ]); + const newCreatedAt = new Date('2020-01-01'); - }, - { - updateMany: { - filter: { _id: users[1]._id }, - update: { ssn: '777-77-7777', createdAt: newCreatedAt } + // Act + await User.bulkWrite([ + { + updateOne: { + filter: { _id: users[0]._id }, + update: { ssn: '888-88-8888', createdAt: newCreatedAt } + }, + timestamps + }, + { + updateMany: { + filter: { _id: users[1]._id }, + update: { ssn: '777-77-7777', createdAt: newCreatedAt } + }, + timestamps } - } - ]); + ]); - // Assert - const [updatedUser1, updatedUser2] = await Promise.all([ - User.findById(users[0]._id), - User.findById(users[1]._id) - ]); - assert.strictEqual(updatedUser1.ssn, '222-22-2222'); - assert.notStrictEqual(updatedUser1.createdAt.valueOf(), newCreatedAt.valueOf()); + // Assert + const [updatedUser1, updatedUser2] = await Promise.all([ + User.findById(users[0]._id), + User.findById(users[1]._id) + ]); + assert.strictEqual(updatedUser1.ssn, '222-22-2222'); + assert.notStrictEqual(updatedUser1.createdAt.valueOf(), newCreatedAt.valueOf()); - assert.strictEqual(updatedUser2.ssn, '333-33-3333'); - assert.notStrictEqual(updatedUser2.createdAt.valueOf(), newCreatedAt.valueOf()); - }); + assert.strictEqual(updatedUser2.ssn, '333-33-3333'); + assert.notStrictEqual(updatedUser2.createdAt.valueOf(), newCreatedAt.valueOf()); + }); + } function createTestContext() { const userSchema = new Schema({