Book.find({ year: { $gte: 1960 } }) does not hit the database. It returns a Query, a mutable builder you keep chaining until you await it or call .exec(). The filter is the driver's filter — every operator from Querying Documents works unchanged — and the chained methods set the options find took there. Given const q = Book.find({ year: { $gte: 1960 } }).sort('-year').limit(5), q.constructor.name is 'Query', q instanceof Promise is false, q.getFilter() reports {"year":{"$gte":1960}} and q.getOptions() reports {"sort":{"year":-1},"limit":5}.
A Query is thenable, not a promise: it has .then(), so await works, but awaiting the same query object twice runs it twice, and Promise.all([q, q]) runs it twice as well. Build a query, execute it once. The inspectors — getFilter(), getOptions(), getUpdate() and explain('queryPlanner'), which returns the plan document Reading an explain Plan read — are how you check what Mongoose 243,355 sends before you argue with an index. Two chainable methods are pure Mongoose: lean() skips hydration, and orFail() turns an empty result into a DocumentNotFoundError so a service stops writing if (!doc) throw ... everywhere.
lean() versus hydrated documents
Hydration is not free. Reading 20,000 books from a local server, five runs each after a warm-up:
hydrated documents: 197 ms 68.7 MB retained lean() plain objects: 75 ms 37.7 MB retained
Two and a half times the wall clock and nearly twice the heap, for documents serialized to JSON and thrown away. If the result is going out as a response, use lean(); if you will call save(), a method, a virtual or a getter on it, do not. That is why the Bookshelf contract of Data Layer returns plain objects.
const out = (d) => { // ObjectId -> string, _id -> id
if (!d) return null;
const { _id, authorId, ...rest } = d;
return { id: String(_id), ...rest, ...(authorId && { authorId: String(authorId) }) };
};
async function list({ limit, offset, q, genre, authorId, sort }) {
const filter = { ...(genre && { genre }), ...(authorId && { authorId }),
...(q && { title: new RegExp(q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i') }) };
const order = { [(sort ?? 'title').replace(/^-/, '')]: sort?.startsWith('-') ? -1 : 1 };
const [total, rows] = await Promise.all([
Book.countDocuments(filter),
Book.find(filter).sort(order).skip(offset).limit(limit).lean().exec()
]);
return { total, items: rows.map(out) };
}
async function get(id) { // 'bk_1' must not throw
return mongoose.isValidObjectId(id) ? out(await Book.findById(id).lean()) : null;
}total 4 | typeof id / authorId -> string / string | get('bk_1') -> nullcreateMongoStore(conn) wraps those in { kind: 'mongo', books: { list, get, ... } }, taking its models from conn.model(...). The out mapper earns its keep twice: lean() returns _id as an ObjectId, not a string, and authorId with it, so leaving them alone lets JSON.stringify hide a problem that breaks the first caller comparing ids. And isValidObjectId guards every id from a URL — without it GET /api/v1/books/bk_1 throws a CastError and the error handler turns a 404 into a 500. With both right, data/index.js changes one line to export const createStore = createMongoStore and the routes, services and all 29 tests run untouched.