Skip to content

BREAKING CHANGE: Async stack traces for custom methods and statics, remove promiseOrCallback() #15376

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Apr 28, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ jobs:
strategy:
fail-fast: false
matrix:
node: [16, 18, 20, 22]
node: [18, 20, 22]
os: [ubuntu-22.04, ubuntu-24.04]
mongodb: [6.0.15, 7.0.12, 8.0.0]
include:
Expand Down
92 changes: 92 additions & 0 deletions docs/migrating_to_9.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,9 +109,101 @@ doc.mySubdoc[0].deleteOne();
await doc.save();
```

## Hooks for custom methods and statics no longer support callbacks

Previously, you could use Mongoose middleware with custom methods and statics that took callbacks.
In Mongoose 9, this is no longer supported.
If you want to use Mongoose middleware with a custom method or static, that custom method or static must be an async function or return a Promise.

```javascript
const mySchema = new Schema({
name: String
});

// This is an example of a custom method that uses callbacks. While this method by itself still works in Mongoose 9,
// Mongoose 9 no longer supports hooks for this method.
mySchema.methods.foo = async function(cb) {
return cb(null, this.name);
};
mySchema.statics.bar = async function(cb) {
return cb(null, 'bar');
};

// This is no longer supported because `foo()` and `bar()` use callbacks.
mySchema.pre('foo', function() {
console.log('foo pre hook');
});
mySchema.pre('bar', function() {
console.log('bar pre hook');
});

// The following code has a custom method and a custom static that use async functions.
// The following works correctly in Mongoose 9: `pre('bar')` is executed when you call `bar()` and
// `pre('qux')` is executed when you call `qux()`.
mySchema.methods.baz = async function baz(arg) {
return arg;
};
mySchema.pre('baz', async function baz() {
console.log('baz pre hook');
});
mySchema.statics.qux = async function qux(arg) {
return arg;
};
mySchema.pre('qux', async function qux() {
console.log('qux pre hook');
});
```

## Removed `promiseOrCallback`

Mongoose 9 removed the `promiseOrCallback` helper function.

```javascript
const { promiseOrCallback } = require('mongoose');

promiseOrCallback; // undefined in Mongoose 9
```

## In `isAsync` middleware `next()` errors take priority over `done()` errors

Due to Mongoose middleware now relying on promises and async/await, `next()` errors take priority over `done()` errors.
If you use `isAsync` middleware, any errors in `next()` will be thrown first, and `done()` errors will only be thrown if there are no `next()` errors.

```javascript
const schema = new Schema({});

schema.pre('save', true, function(next, done) {
execed.first = true;
setTimeout(
function() {
done(new Error('first done() error'));
},
5);

next();
});

schema.pre('save', true, function(next, done) {
execed.second = true;
setTimeout(
function() {
next(new Error('second next() error'));
done(new Error('second done() error'));
},
25);
});

// In Mongoose 8, with the above middleware, `save()` would error with 'first done() error'
// In Mongoose 9, with the above middleware, `save()` will error with 'second next() error'
```

## Removed `skipOriginalStackTraces` option

In Mongoose 8, Mongoose queries store an `_executionStack` property that stores the stack trace of where the query was originally executed for debugging `Query was already executed` errors.
This behavior can cause performance issues with bundlers and source maps.
`skipOriginalStackTraces` was added to work around this behavior.
In Mongoose 9, this option is no longer necessary because Mongoose no longer stores the original stack trace.

## Node.js version support

Mongoose 9 requires Node.js 18 or higher.
14 changes: 2 additions & 12 deletions lib/helpers/model/applyHooks.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
'use strict';

const symbols = require('../../schema/symbols');
const promiseOrCallback = require('../promiseOrCallback');

/*!
* ignore
Expand Down Expand Up @@ -129,17 +128,8 @@ function applyHooks(model, schema, options) {
continue;
}
const originalMethod = objToDecorate[method];
objToDecorate[method] = function() {
const args = Array.prototype.slice.call(arguments);
const cb = args.slice(-1).pop();
const argsWithoutCallback = typeof cb === 'function' ?
args.slice(0, args.length - 1) : args;
return promiseOrCallback(cb, callback => {
return this[`$__${method}`].apply(this,
argsWithoutCallback.concat([callback]));
}, model.events);
};
objToDecorate[`$__${method}`] = middleware.
objToDecorate[`$__${method}`] = objToDecorate[method];
objToDecorate[method] = middleware.
createWrapper(method, originalMethod, null, customMethodOptions);
}
}
Expand Down
36 changes: 1 addition & 35 deletions lib/helpers/model/applyStaticHooks.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
'use strict';

const promiseOrCallback = require('../promiseOrCallback');
const { queryMiddlewareFunctions, aggregateMiddlewareFunctions, modelMiddlewareFunctions, documentMiddlewareFunctions } = require('../../constants');

const middlewareFunctions = Array.from(
Expand Down Expand Up @@ -28,40 +27,7 @@ module.exports = function applyStaticHooks(model, hooks, statics) {
if (hooks.hasHooks(key)) {
const original = model[key];

model[key] = function() {
const numArgs = arguments.length;
const lastArg = numArgs > 0 ? arguments[numArgs - 1] : null;
const cb = typeof lastArg === 'function' ? lastArg : null;
const args = Array.prototype.slice.
call(arguments, 0, cb == null ? numArgs : numArgs - 1);
return promiseOrCallback(cb, callback => {
hooks.execPre(key, model, args).then(() => onPreComplete(null), err => onPreComplete(err));

function onPreComplete(err) {
if (err != null) {
return callback(err);
}

let postCalled = 0;
const ret = original.apply(model, args.concat(post));
if (ret != null && typeof ret.then === 'function') {
ret.then(res => post(null, res), err => post(err));
}

function post(error, res) {
if (postCalled++ > 0) {
return;
}

if (error != null) {
return callback(error);
}

hooks.execPost(key, model, [res]).then(() => callback(null, res), err => callback(err));
}
}
}, model.events);
};
model[key] = hooks.createWrapper(key, original);
}
}
};
54 changes: 0 additions & 54 deletions lib/helpers/promiseOrCallback.js

This file was deleted.

7 changes: 0 additions & 7 deletions lib/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ const isBsonType = require('./helpers/isBsonType');
const isPOJO = require('./helpers/isPOJO');
const getFunctionName = require('./helpers/getFunctionName');
const isMongooseObject = require('./helpers/isMongooseObject');
const promiseOrCallback = require('./helpers/promiseOrCallback');
const schemaMerge = require('./helpers/schema/merge');
const specialProperties = require('./helpers/specialProperties');
const { trustedSymbol } = require('./helpers/query/trusted');
Expand Down Expand Up @@ -197,12 +196,6 @@ exports.last = function(arr) {
return void 0;
};

/*!
* ignore
*/

exports.promiseOrCallback = promiseOrCallback;

/*!
* ignore
*/
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@
"main": "./index.js",
"types": "./types/index.d.ts",
"engines": {
"node": ">=16.20.1"
"node": ">=18.0.0"
},
"bugs": {
"url": "https://github.com/Automattic/mongoose/issues/new"
Expand Down
25 changes: 22 additions & 3 deletions test/document.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4534,20 +4534,24 @@ describe('document', function() {
assert.equal(p.children[0].grandchild.foo(), 'bar');
});

it('hooks/middleware for custom methods (gh-6385) (gh-7456)', async function() {
it('hooks/middleware for custom methods (gh-6385) (gh-7456)', async function hooksForCustomMethods() {
const mySchema = new Schema({
name: String
});

mySchema.methods.foo = function(cb) {
return cb(null, this.name);
mySchema.methods.foo = function() {
return Promise.resolve(this.name);
};
mySchema.methods.bar = function() {
return this.name;
};
mySchema.methods.baz = function(arg) {
return Promise.resolve(arg);
};
mySchema.methods.qux = async function qux() {
await new Promise(resolve => setTimeout(resolve, 5));
throw new Error('error!');
};

let preFoo = 0;
let postFoo = 0;
Expand All @@ -4567,6 +4571,15 @@ describe('document', function() {
++postBaz;
});

let preQux = 0;
let postQux = 0;
mySchema.pre('qux', function() {
++preQux;
});
mySchema.post('qux', function() {
++postQux;
});

const MyModel = db.model('Test', mySchema);


Expand All @@ -4588,6 +4601,12 @@ describe('document', function() {
assert.equal(await doc.baz('foobar'), 'foobar');
assert.equal(preBaz, 1);
assert.equal(preBaz, 1);

const err = await doc.qux().then(() => null, err => err);
assert.equal(err.message, 'error!');
assert.ok(err.stack.includes('hooksForCustomMethods'));
assert.equal(preQux, 1);
assert.equal(postQux, 0);
});

it('custom methods with promises (gh-6385)', async function() {
Expand Down
Loading