Queries and the Query Builder

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:

Output of 107
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.

src/data/mongo.js — two of the contract methods, on a Book modelJavaScript
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;
}
Output
total 4 | typeof id / authorId -> string / string | get('bk_1') -> null

createMongoStore(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.