Three fluent, chainable, allowlisted query builders for Mongoose — QueryFind for simple Model.find() queries, QueryAggregate for anything that needs joins, grouping, timezone-aware date filtering, or advanced pagination, and QueryAnalytics for dashboards and reporting (time series, cohorts, funnels, sessionization, and more). All three are driven directly from URL query parameters with zero boilerplate.
QueryFind
- Installation
- Quick Start
- Constructor
- Builder Methods
- Terminal Method
- Query Parameter Reference
- Full Example (Express)
- More Examples
- TypeScript Types
- Security
- Links
- License
QueryAggregate
- Overview
- When to use QueryAggregate vs QueryFind
- Installation
- Quick Start
- Constructor & Options
- Timezone Handling
- Builder Methods
- Terminal & Utility Methods
- Recipes
- Pipeline Stage Ordering
- Security
- TypeScript Types
- Links
- License
QueryAnalytics
- Overview
- When to use QueryAnalytics
- Installation
- Quick Start
- Constructor & Options
- Base Filter Methods
- Time Series
- Comparisons & Breakdowns
- Funnels & Cohorts
- Statistics
- Post-Processing Helpers (static)
- .heatmap()
- Live Updates
- Export & Batch Helpers (static)
- Recipes
- Security
- TypeScript Types
- Links
- License
npm install mongoose-query-findyarn add mongoose-query-findpnpm add mongoose-query-findPeer dependency: requires
mongoose ^8ormongoose ^9installed in your project.
import { queryFind } from 'mongoose-query-find';
import UserModel from './models/user';
const result = await queryFind(UserModel.find(), req.query)
.allowFields(['name', 'email', 'role', 'createdAt'])
.where({ deletedAt: null })
.filter()
.globalSearch(['name', 'email'])
.sort()
.limitFields('-password -__v')
.paginate();result will look like:
{
"data": [...],
"total": 84,
"page": 2,
"totalPages": 9,
"limit": 10,
"hasNextPage": true,
"hasPrevPage": true
}new QueryFind(query, queryString, options?);
// or use the factory function (recommended):
queryFind(query, queryString, options?);| Parameter | Type | Description |
|---|---|---|
query |
Query<TRawDocType[], TRawDocType> |
A Mongoose query, e.g. Model.find() |
queryString |
QueryParams |
The parsed URL query object, e.g. req.query |
options |
QueryFindOptions (optional) |
Configuration options (see Options below) |
| Option | Type | Default | Description |
|---|---|---|---|
maxTimeMS |
number |
5000 |
Max milliseconds MongoDB may spend on each query. Pass 0 to disable. |
slowQueryThresholdMS |
number |
maxTimeMS |
Threshold above which onSlowQuery fires. |
maxLimit |
number |
100 |
Hard cap on ?limit=. |
lean |
boolean |
true |
Whether .find() calls .lean() for faster plain-object reads. |
onSlowQuery |
(info: SlowQueryInfo) => void |
— | Callback fired when a query exceeds slowQueryThresholdMS. |
onSanitizeDrop |
(path: string, value: unknown) => void |
— | Callback fired whenever sanitization drops a client-supplied value. |
All builder methods return this and are fully chainable. The recommended call order is:
allowFields → where → filter → globalSearch → sort → limitFields → populate → paginate
Declares which fields may appear in URL filters, sort parameters, and field projections. Acts as an allowlist — any field not listed is silently stripped from client input before it reaches MongoDB.
Call this before .filter(), .sort(), and .limitFields().
.allowFields(['name', 'email', 'role', 'createdAt'])Parses the URL query string into a Mongoose filter. Automatically:
- Strips reserved keys (
page,limit,sort,fields,q) - Converts comparison operator names to MongoDB
$syntax (gt→$gt,lte→$lte, etc.) - Recursively coerces string booleans to real booleans (
"true"→true,"false"→false) - Coerces date-like strings to
Dateinstances for fields namedcreatedAt,updatedAt,deletedAt,date,birthDate, orexpiresAt - Recursively validates and rejects banned operators (
$where,$expr,$function, etc.)
Supported operators: eq, ne, gt, gte, lt, lte, in, nin
GET /users?age[gte]=18&isActive=true&role=admin
.filter()
// → { age: { $gte: 18 }, isActive: true, role: 'admin' }Applies mandatory server-side conditions that the URL cannot override. Use this for multi-tenancy, soft-delete exclusion, and any security-critical constraints.
.where({ orgId: req.user.orgId, deletedAt: null })
.where()always wins — conditions are merged after.filter()and will overwrite any conflicting URL params.
Adds a case-insensitive $or regex search across the specified fields when the ?q= query parameter is present. If q is absent or exceeds 200 characters, this method is a no-op.
Search terms are regex-escaped to prevent ReDoS attacks.
GET /users?q=john
.globalSearch(['name', 'email'])
// → { $or: [{ name: /john/i }, { email: /john/i }] }If a $or clause already exists in the filter (e.g. from .filter()), both are safely merged under $and.
Applies sort order from the ?sort= query parameter. Prefix a field with - for descending order. Multiple fields are comma-separated, capped at 5 fields.
GET /users?sort=-createdAt,name
.sort()
// → sorts by createdAt DESC, then name ASCDefaults to { createdAt: -1 } when the sort param is absent. Fields not in the allowlist are silently skipped.
Controls which fields are returned (projection). Uses the ?fields= query param when present, otherwise falls back to defaultFields.
Fields requested via ?fields= are filtered against the allowlist — clients cannot project sensitive fields like password or resetToken.
Priority order:
?fields=query param — always wins when present (allowlist-filtered)defaultFieldsargument — used as fallback when no query param- No projection when both are absent (all fields returned)
GET /users?fields=name,email,role
.limitFields('-password -__v')
// With ?fields=name,email,role → selects only name, email, role
// Without ?fields → excludes password and __vRegisters a populate directive. Chainable — each call appends to the internal list. All registered populates are applied inside paginate().
Accepts the same arguments as Mongoose's own .populate():
// Plain path string
.populate('author')
// Path + select string
.populate('author', 'name email')
// Full PopulateOptions object
.populate({ path: 'comments', select: 'text createdAt', match: { visible: true } })
// Multiple calls — fully chainable
.populate('author')
.populate({ path: 'comments', select: 'text createdAt' })Executes the query and returns a Promise<PaginatedResult<T>>.
countDocumentsandfindrun in parallel viaPromise.all(saves one network round-trip).- Uses
estimatedDocumentCountas a fast-path when no filter is applied (O(1) vs O(n)). .lean()is applied for ~3–5× faster reads on plain object responses.maxTimeMSis applied to both count and find to prevent runaway collection scans.- If the requested
pageexceedstotalPages(e.g. after a deletion), page1is returned automatically.
GET /users?page=2&limit=20
Returns:
{
data: T[];
total: number; // Total matching documents across all pages
page: number; // Current page (auto-corrects to 1 if out of range)
totalPages: number;
limit: number;
hasNextPage: boolean;
hasPrevPage: boolean;
}Defaults: page=1, limit=10, max limit=100.
| Parameter | Example | Description |
|---|---|---|
page |
?page=3 |
Page number (default: 1, min: 1) |
limit |
?limit=25 |
Documents per page (default: 10, max: 100) |
sort |
?sort=-createdAt,name |
Sort fields; prefix - for descending (max: 5 fields) |
fields |
?fields=name,email |
Comma-separated fields to include in the response |
q |
?q=john |
Global search term (max: 200 chars) |
| (any key) | ?role=admin&age[gte]=18 |
Field-level filters processed by .filter() |
import { Request, Response } from 'express';
import { queryFind } from 'mongoose-query-find';
import UserModel from '../models/user';
export const getUsers = async (req: Request, res: Response) => {
const result = await queryFind(UserModel.find(), req.query)
.allowFields(['name', 'email', 'username', 'role', 'isActive', 'createdAt'])
.where({ orgId: req.user.orgId, deletedAt: null })
.filter()
.globalSearch(['name', 'email', 'username'])
.sort()
.limitFields('-password -__v')
.populate('role', 'name permissions')
.paginate();
res.json({ status: 'success', ...result });
};Example requests:
# Page 2, 15 per page, only active admins sorted by name
GET /users?page=2&limit=15&role=admin&isActive=true&sort=name
# Search "alice" across name, email, and username
GET /users?q=alice
# Users older than 25, return only name and email
GET /users?age[gt]=25&fields=name,email
# Filter by exact date
GET /users?createdAt=2024-06-01
# Filter by date range
GET /users?createdAt[gte]=2024-01-01&createdAt[lte]=2024-12-31
# Combined: search + filter + sort + pagination
GET /users?q=john&role=editor&sort=-createdAt&page=1&limit=5E-commerce product listing — nested price filters + category populate:
export const getProducts = async (req: Request, res: Response) => {
const result = await queryFind(ProductModel.find(), req.query)
.allowFields(['name', 'price', 'category', 'inStock', 'brand', 'createdAt'])
.where({ deletedAt: null, published: true })
.filter()
.globalSearch(['name', 'brand'])
.sort()
.limitFields('name price inStock brand')
.populate('category', 'name slug')
.paginate();
res.json(result);
};# Price between $20 and $100, in stock, sorted cheapest first
GET /products?price[gte]=20&price[lte]=100&inStock=true&sort=price
# Search "nike" across name and brand
GET /products?q=nike&sort=-createdAtBlog posts — multiple populates + logical operators:
export const getPosts = async (req: Request, res: Response) => {
const result = await queryFind(PostModel.find(), req.query)
.allowFields(['title', 'status', 'authorId', 'tags', 'publishedAt'])
.where({ deletedAt: null })
.filter()
.globalSearch(['title'])
.sort()
.limitFields('title status publishedAt')
.populate('authorId', 'name avatar')
.populate({
path: 'comments',
select: 'text createdAt',
match: { visible: true },
})
.paginate();
res.json(result);
};# Published OR featured posts, using the safe top-level $or
GET /posts?$or[0][status]=published&$or[1][featured]=true
# Posts tagged "typescript" published this year
GET /posts?tags=typescript&publishedAt[gte]=2026-01-01Admin panel — strict field allowlist, sensitive fields never leak:
export const adminListUsers = async (req: Request, res: Response) => {
const result = await queryFind(UserModel.find(), req.query, { maxLimit: 250 })
.allowFields(['name', 'email', 'role', 'isActive', 'lastLoginAt'])
.where({ orgId: req.user.orgId })
.filter()
.sort()
.limitFields('-password -passwordResetToken -__v') // client's ?fields= can never override this
.paginate();
res.json(result);
};# Even if a client tries ?fields=password, it's stripped — not in allowFields()
GET /admin/users?fields=password,email&role=adminCached count pattern — skip re-counting on subsequent pages:
export const getNotifications = async (req: Request, res: Response) => {
const builder = queryFind(NotificationModel.find(), req.query)
.allowFields(['userId', 'read', 'createdAt'])
.where({ userId: req.user.id })
.filter()
.sort();
// First request from the client includes no cachedTotal
const cachedTotal = req.query.cachedTotal
? Number(req.query.cachedTotal)
: undefined;
const result = await builder.paginate(cachedTotal);
res.json(result); // client stores result.total and sends it back as ?cachedTotal= on the next page
};All types are exported:
import {
queryFind,
QueryFind,
QueryParams,
PaginatedResult,
QueryFindOptions,
QueryFindError,
QueryFindValidationError,
SlowQueryInfo,
} from 'mongoose-query-find';interface QueryParams {
page?: string;
limit?: string;
sort?: string;
fields?: string;
q?: string;
[key: string]: unknown;
}
interface PaginatedResult<T> {
data: T[];
total: number;
page: number;
totalPages: number;
limit: number;
hasNextPage: boolean;
hasPrevPage: boolean;
}
interface QueryFindOptions {
maxTimeMS?: number; // default: 5000
slowQueryThresholdMS?: number; // default: maxTimeMS
maxLimit?: number; // default: 100
lean?: boolean; // default: true
onSlowQuery?: (info: SlowQueryInfo) => void;
onSanitizeDrop?: (path: string, value: unknown) => void;
}
interface SlowQueryInfo {
elapsedMs: number;
filter: Record<string, unknown>;
sort: Record<string, 1 | -1>;
page: number;
limit: number;
}| Protection | Detail |
|---|---|
| NoSQL injection | Allowlist enforced on filters, sort, and projection via .allowFields() |
| Banned operators | $where, $expr, $function, and others are rejected recursively at any nesting depth |
| ReDoS | Search terms are regex-escaped before compilation |
| Oversized search | ?q= capped at 200 characters |
| Deep nesting DoS | Filter object nesting capped at depth 5 |
| Page size DoS | limit hard-capped at 100 |
| Sort abuse | Sort fields hard-capped at 5 |
| Runaway queries | maxTimeMS applied to both count and find (default: 5s) |
| Sensitive field leakage | ?fields= projection stripped against allowlist |
ISC © jsdev.robin@gmail.com
A fluent, allowlisted aggregation-pipeline builder for Mongoose (^8 || ^9), built on Model.aggregate(). It mirrors the same safety model as QueryFind — allowlisted filter fields, sanitized/rejected operators, capped pagination — but adds $lookup joins, $group, timezone-aware date filtering, custom date/hour ranges, keyset pagination, and one-round-trip dashboard stats.
QueryFind is built on Model.find() and can't express joins or grouping. QueryAggregate solves that while keeping the same guardrails, plus a lot more:
- Allowlisted filter/sort/projection fields
- Recursive sanitization of client input (drops class instances, functions, over-deep nesting)
- Banned-operator rejection (
$where,$expr,$function,$accumulator,$map,$reduce,$filter) - Capped pagination (
limit,page) - Timezone-aware date coercion,
$lookupjoins, arbitrary developer-authored pipeline stages via.addStage() - Single-round-trip pagination via
$facet - New: six custom date-range methods (arbitrary day, arbitrary span, rolling N-days/N-hours, hour-of-day windows, absolute datetime windows)
- New:
count(),distinct(), andstats()for cheap metrics without fetching a data page - New:
stream()for memory-safe large exports, andpaginateCursor()for keyset pagination on huge collections - New:
explain(),debugPipeline(), andclone()for debugging and query reuse
| Need | Use |
|---|---|
| Simple filter/sort/paginate on one collection | QueryFind |
Joining another collection ($lookup) |
QueryAggregate |
Grouping / rollups / computed fields ($group, $addFields) |
QueryAggregate |
| Client filters need to respect the caller's local timezone | QueryAggregate |
| A custom day / date span / rolling window / hour-of-day filter | QueryAggregate |
| Dashboard totals (sum/avg/count) without fetching a page | QueryAggregate |
Very large collections where $skip pagination gets slow |
QueryAggregate |
| Streaming a large export without buffering it in memory | QueryAggregate |
| You need a custom developer-defined pipeline stage | QueryAggregate |
Ships from the same package as QueryFind:
npm install mongoose-query-findimport { QueryAggregate } from 'mongoose-query-find';
import CategoryModel from './models/category';
const timezone = (req.headers['x-timezone'] as string) ?? 'Asia/Dhaka';
const result = await new QueryAggregate(CategoryModel, req.query, { timezone })
.allowFields(['name', 'status', 'createdAt'])
.dateFields(['createdAt'])
.where({ branchId: req.self.linkedTo, status: { $ne: 'ARCHIVED' } })
.filter()
.globalSearch(['name'])
.sort()
.project('name status createdAt updatedAt')
.paginate();result shape is identical to QueryFind's PaginatedResult<T>:
{
"data": [...],
"total": 84,
"page": 2,
"totalPages": 9,
"limit": 10,
"hasNextPage": true,
"hasPrevPage": true
}new QueryAggregate<T>(model, queryString, options?);| Parameter | Type | Description |
|---|---|---|
model |
Model<T> |
A Mongoose model |
queryString |
QueryParams |
Parsed URL query object, e.g. req.query |
options |
QueryAggregateOptions |
Configuration options (see below), all optional |
| Option | Type | Default | Description |
|---|---|---|---|
timezone |
string |
'Asia/Dhaka' |
IANA timezone for interpreting client-supplied local dates. Invalid values fall back to the default (validated once at construction, never throws mid-query). |
maxTimeMS |
number |
5000 |
Max time MongoDB may spend per query. 0 disables. |
slowQueryThresholdMS |
number |
maxTimeMS |
Threshold above which onSlowQuery fires. |
maxLimit |
number |
100 |
Hard cap on ?limit=. |
allowDiskUse |
boolean |
false |
Forwarded to .allowDiskUse() — enable for large sort/group pipelines. |
weekStartsOn |
0–6 |
6 (Saturday) |
Used by .range('thisWeek'). |
onSlowQuery |
(info: SlowQueryInfo) => void |
— | Callback fired when a query exceeds slowQueryThresholdMS. |
onSanitizeDrop |
(path: string, value: unknown) => void |
— | Callback fired whenever sanitization drops a value (bad nesting, non-plain value). |
MongoDB always stores/compares dates in UTC. Clients think and filter in local time — ?createdAt=2026-09-01 means "Sept 1st in the caller's timezone," not UTC midnight.
fromZonedTime(localString, tz)→ exact UTC instant for a wall-clock time intz. Used to turn a client-supplied local date into the correct$gte/$ltUTC range in$match.toZonedTime(utcDate, tz)→ wall-clockDatefortz. Used to compute "what day is it right now, in the caller's timezone" for.range()presets and the rolling-N-days custom range.
The timezone is a per-request value, typically sourced from a header:
const timezone = (req.headers['x-timezone'] as string) ?? 'Asia/Dhaka';An invalid/unknown IANA timezone string (bad header, typo) never throws — it's validated once at construction (resolveTimezone) and silently falls back to the default.
Plain YYYY-MM-DD filter values are expanded into a $gte/$lt range spanning that local day; values with an explicit offset (Z, +06:00) are treated as absolute instants and passed through as-is. Every custom range method below applies the same timezone-aware logic.
All builder methods return this and are fully chainable. Recommended call order:
allowFields → dateFields/objectIdFields → where → range/rangeXxx → filter → globalSearch → sort → project → lookup/addStage/computeField → paginate/paginateCursor/count/distinct/stats/stream
Same role as in QueryFind: declares which fields the client may filter, sort, and project. Case-insensitive. Call before .filter(), .sort(), .project().
.allowFields(['name', 'status', 'createdAt'])Explicit list of fields treated as dates for timezone-aware coercion. When omitted, falls back to a name heuristic matching createdAt, updatedAt, deletedAt, archivedAt, expiresAt, date, birthDate (only when that word is the last path segment, e.g. user.updatedAt matches, updatedAtSomething doesn't).
.dateFields(['createdAt', 'updatedAt'])Explicit list of fields treated as ObjectId references, so a client-supplied 24-char hex string (?categoryId=...) is coerced to Types.ObjectId instead of being compared as a plain string (which would never match, since it's a different BSON type). When omitted, falls back to a name heuristic matching _id or any field ending in Id (e.g. categoryId, basicInfo.brandId).
.objectIdFields(['branchId', 'categoryId'])Parses req.query into the aggregation $match stage's client-derived portion. Runs, in order:
- Strips reserved keys (
page,sort,limit,fields,q) sanitize()— recursively drops non-plain values (class instances, functions), capped at nesting depth 5coerceAll()— timezone-aware date coercion + ObjectId coercion + boolean coercion + numeric coercion + operator-name coercion (gt→$gt, etc.)enforceAllowlist()— strips any non-operator key not in.allowFields()rejectDisallowedOperators()— throwsQueryAggregateValidationErroron$where,$expr,$function,$accumulator,$map,$reduce,$filter, or any unexpected top-level$operator
GET /categories?createdAt=2026-09-01&status=ACTIVE
.filter()
// → { createdAt: { $gte: <UTC midnight in tz>, $lt: <next UTC midnight in tz> }, status: 'ACTIVE' }Mandatory server-side conditions the client cannot override — kept in a separate internal bucket (_serverConditions) and merged last via $and, so URL params can never win against it.
.where({ branchId: req.self.linkedTo, status: { $ne: 'ARCHIVED' } })Adds a hard, server-defined UTC date range on field, computed in this instance's timezone. Not client-overridable (goes into _serverConditions, same as .where()).
Presets: 'today', 'yesterday', 'last7Days', 'last30Days', 'thisWeek', 'thisMonth'
.range('createdAt', 'last7Days').range('thisWeek') respects the weekStartsOn constructor option (defaults to Saturday).
Beyond the fixed RangePreset values above, six additional methods cover any custom day, span, rolling window, or hour-based filter — without touching .range()'s own logic. All of them behave exactly like .range(): they write a hard, non-client-overridable condition into _serverConditions, in this instance's timezone, and throw QueryAggregateValidationError on bad input.
⚠️ Only call one range method per field. If you call two on the same field (e.g..range()then.rangeCustom()oncreatedAt), the later call simply overwrites the earlier one.
A single arbitrary local calendar date (YYYY-MM-DD) — for "show me everything from this specific day" rather than a relative preset.
.rangeCustomDay('createdAt', '2026-03-14')An arbitrary inclusive local-date span — the classic "from / to" date-picker filter.
.rangeBetween('createdAt', { from: '2026-01-01', to: '2026-01-31' })A rolling window of the last N whole calendar days, inclusive of today — a parametrized version of the built-in last7Days/last30Days presets.
.rangeLastNDays('createdAt', 14) // last 14 calendar daysA rolling window of the last N hours, ending at the exact moment the query runs. Unlike the day-based methods, this uses raw UTC instants with no calendar-day snapping — ideal for "recent activity" widgets.
.rangeLastNHours('createdAt', 6) // activity in the last 6 hoursAn explicit hour-of-day window on a specific local date — e.g. filtering orders placed during business hours.
.rangeHours('createdAt', { date: '2026-03-14', fromHour: 9, toHour: 17 })
// toHour is exclusive; pass 24 for "through end of day"The most flexible option — an explicit absolute datetime window. Each of from/to accepts:
"YYYY-MM-DD"→ local midnight in this instance's timezone"YYYY-MM-DDTHH:mm:ss[.sss]"→ local wall-clock time in this instance's timezone- an offset-bearing ISO string (
...Z,...+06:00) → treated as an absolute instant
.rangeCustom('createdAt', {
from: '2026-03-14T09:30:00', // local wall-clock
to: '2026-03-14T18:45:00',
})
// or mixed with an explicit UTC instant:
.rangeCustom('createdAt', { from: '2026-03-14T00:00:00Z', to: '2026-03-15T00:00:00Z' })Convenience server-side condition excluding soft-deleted documents. Matches both null and a genuinely missing field, per MongoDB's equality semantics. Not client-overridable.
.excludeSoftDeleted() // defaults to 'deletedAt'
.excludeSoftDeleted('removedAt') // or a custom field nameApplies .where(conditions) only when condition is truthy — avoids scattering if statements around a chained builder call.
.whereIf(req.query.vip === 'true', { tier: 'vip' })
.whereIf(req.self.role !== 'admin', { branchId: req.self.linkedTo })Identical behavior to QueryFind: case-insensitive $or regex search across the given fields when ?q= is present (max 200 chars). Regex-escaped to prevent ReDoS. Fields are filtered against the allowlist first.
.globalSearch(['name'])Parses ?sort= (e.g. -createdAt,name), capped at 5 fields, dropping any field not in the allowlist. Falls back to { createdAt: -1 } when absent.
.sort()
.paginateCursor()(below) seeks on this same sort configuration, so call.sort()before it if you need a field other than the defaultcreatedAt.
Controls the $project stage. Accepts a space-separated string ("name status -_id") or a raw { field: 0 | 1 } object.
?fields=(when present) is parsed and filtered through the allowlist.- If every requested field is blocked, falls back to
defaultProjectionrather than silently returning everything. _idand__vare always allowed through even if not inallowFields().
.project('name status createdAt updatedAt')
⚠️ If a preceding.addStage()(e.g.$group) reshapes documents,.project()'s allowlist still only knows about the fields passed to.allowFields()— new computed fields may be silently dropped. Use.addStage({ $project: {...} })instead when you need full control post-reshape.
Adds a $lookup (join) stage, optionally followed by $unwind.
| Field | Type | Description |
|---|---|---|
from |
string |
Collection name (not the model name) to join against. |
as |
string |
Output array field name. |
localField |
string? |
Local join field. |
foreignField |
string? |
Foreign join field. |
pipeline |
PipelineStage[]? |
Sub-pipeline form of $lookup, for complex/correlated joins. Cannot contain $out/$merge. |
single |
boolean? |
When true, $unwinds the joined array (preserveNullAndEmptyArrays: true) so as becomes a single object instead of an array. |
.lookup({
from: 'branches',
as: 'branch',
localField: 'branchId',
foreignField: '_id',
single: true,
})Convenience wrapper over .addStage() for a single computed $addFields entry.
.computeField('margin', { $subtract: ['$price', '$cost'] })
// equivalent to: .addStage({ $addFields: { margin: { $subtract: ['$price', '$cost'] } } })Escape hatch for any other developer-authored stage: $addFields, $group, a post-join $match, a custom $sort, etc.
- Blocks only
$out/$merge(destructive stages). - Does not run the client-input sanitizer/allowlist/operator-rejection — that only applies to
.filter()/.where(), which parse untrustedreq.query/req.bodyvalues. - Never pass raw client input directly into
.addStage(). It's for stages you author server-side. If you need a client-supplied value inside a custom stage, validate/whitelist it yourself first.
.addStage({
$group: {
_id: '$status',
count: { $sum: 1 },
totalAmount: { $sum: '$amount' },
},
})Executes the pipeline and returns Promise<PaginatedResult<T>>.
- Uses
$facetto fetch the data page and total count in a single round trip. - Pass a prior page-1
totalascachedTotalon later pages to skip the count stage entirely. maxTimeMS/allowDiskUsefrom constructor options are applied to the aggregation.- If the requested
pageexceedstotalPages, page1is returned automatically (with the realtotal/totalPagesstill reported). onSlowQuery(if configured) fires with{ elapsedMs, pipeline, sort, page, limit }when the query exceedsslowQueryThresholdMS.
Note:
$facetbuffers both branches in memory per query. For very large collections with expensive pre-facet stages (heavy$lookup/$groupbefore pagination), passcachedTotalon subsequent pages, use.paginateCursor()instead, or run.count()/data as two separate calls.
const page1 = await builder.paginate();
const page2 = await builder.paginate(page1.total); // skips re-countingKeyset ("seek") pagination — scales far better than .paginate()'s $skip on deep pages of large collections, at the cost of not supporting "jump to page N". Seeks on whatever field .sort() resolved to (falls back to createdAt if .sort() wasn't called), using _id as a stable tiebreaker.
interface CursorPaginateOptions {
cursor?: string; // opaque cursor from a previous page's nextCursor
limit?: number; // overrides ?limit=, still capped by maxLimit
}
interface CursorPaginatedResult<T> {
data: T[];
limit: number;
nextCursor: string | null; // pass this back in as `cursor` for the next page
hasNextPage: boolean;
}const p1 = await builder.sort().paginateCursor({ limit: 20 });
const p2 = await builder.paginateCursor({ cursor: p1.nextCursor!, limit: 20 });
// hasNextPage: false / nextCursor: null means you've reached the endGreat fit for infinite-scroll feeds and large-export pagination; not a drop-in replacement where the UI needs page numbers.
Returns just the matching document count — cheaper than .paginate() when you don't need the data page. Ignores .project()/.sort().
const total = await builder.count();Returns the distinct values of field under the current .filter()/.where()/range conditions.
const statuses = await builder.distinct('status');
// e.g. ['PENDING', 'PAID', 'CANCELLED']Computes one or more aggregate metrics — sum, avg, min, max, count — in a single round trip. Ideal for dashboard totals. Ignores .project()/.sort()/pagination.
interface StatSpec {
name: string;
op: 'sum' | 'avg' | 'min' | 'max' | 'count';
field?: string; // required for every op except 'count'
}const totals = await builder.stats([
{ name: 'revenue', op: 'sum', field: 'total' },
{ name: 'avgOrder', op: 'avg', field: 'total' },
{ name: 'orders', op: 'count' },
]);
// { revenue: 48213, avgOrder: 62.1, orders: 776 }Returns a Mongoose AggregationCursor — async-iterable, no buffering of the full result set in memory. Use for large exports (CSV generation, bulk processing).
for await (const doc of builder.stream()) {
csvWriter.write(doc);
}Ignores page/limit; add your own $limit via .addStage() if you want a hard cap.
Runs the built pipeline through Mongoose's .explain() — for debugging slow queries and verifying index usage. verbosity is 'queryPlanner' (default), 'executionStats', or 'allPlansExecution'.
const plan = await builder.explain('executionStats');Development/ops tool only — never call this in a production request path.
Deep-copies the current builder state (filters, sort, projection, stages) into a new, independent QueryAggregate instance. Handy for branching one shared base query into e.g. both .paginate() and .stats() without rebuilding the filters twice.
const base = builder.filter().sort().project('name total createdAt');
const [page, totals] = await Promise.all([
base.clone().paginate(),
base.clone().stats([{ name: 'revenue', op: 'sum', field: 'total' }]),
]);Returns the fully-built pipeline (PipelineStage[]) without executing it — for logging, snapshot tests, or sanity-checking what will actually run.
console.log(builder.debugPipeline());Join + post-join filter + group + paginate:
const result = await new QueryAggregate(OrderModel, req.query, { timezone })
.allowFields(['status', 'createdAt'])
.dateFields(['createdAt'])
.filter()
.lookup({
from: 'customers',
as: 'customer',
localField: 'customerId',
foreignField: '_id',
single: true,
})
.addStage({ $match: { 'customer.vip': true } }) // server-defined, not client-controlled
.addStage({
$group: {
_id: '$customer._id',
customerName: { $first: '$customer.name' },
orderCount: { $sum: 1 },
},
})
.sort()
.paginate();Relative date range preset (server-enforced, not client-overridable):
const result = await new QueryAggregate(OrderModel, req.query, { timezone })
.allowFields(['status'])
.where({ branchId: req.self.linkedTo })
.range('createdAt', 'last30Days')
.filter()
.sort()
.paginate();Custom hour-of-day window (business hours report):
const result = await new QueryAggregate(OrderModel, req.query, { timezone })
.allowFields(['status'])
.where({ branchId: req.self.linkedTo })
.rangeHours('createdAt', {
date: req.query.day as string,
fromHour: 9,
toHour: 21,
})
.filter()
.sort()
.paginate();Dashboard: page + totals in parallel, off one shared base:
const base = new QueryAggregate(OrderModel, req.query, { timezone })
.allowFields(['status', 'branchId', 'createdAt', 'total'])
.dateFields(['createdAt'])
.where({ branchId: req.self.linkedTo })
.excludeSoftDeleted()
.filter()
.sort()
.project('status createdAt total');
const [page, totals] = await Promise.all([
base.clone().paginate(),
base.clone().stats([
{ name: 'revenue', op: 'sum', field: 'total' },
{ name: 'orders', op: 'count' },
]),
]);Infinite scroll with keyset pagination:
const builder = new QueryAggregate(PostModel, req.query, { timezone })
.allowFields(['authorId', 'createdAt'])
.where({ published: true })
.filter()
.sort();
const page = await builder.paginateCursor({
cursor: req.query.cursor as string | undefined,
limit: 20,
});Streaming a large CSV export:
const builder = new QueryAggregate(OrderModel, req.query, { timezone })
.allowFields(['status', 'createdAt'])
.where({ branchId: req.self.linkedTo })
.range('createdAt', 'thisMonth')
.filter()
.project('status createdAt total');
res.setHeader('Content-Type', 'text/csv');
for await (const row of builder.stream()) {
res.write(`${row.status},${row.createdAt.toISOString()},${row.total}\n`);
}
res.end();ObjectId filter fields (ref lookups by hex ID from the URL):
GET /orders?branchId=64f1a2b3c4d5e6f7a8b9c0d1&categoryId=64f1a2b3c4d5e6f7a8b9c0d2
const result = await new QueryAggregate(OrderModel, req.query, { timezone })
.allowFields(['branchId', 'categoryId', 'status'])
.objectIdFields(['branchId', 'categoryId']) // coerces hex strings -> Types.ObjectId
.filter()
.sort()
.paginate();Role-based server conditions with .whereIf() + .excludeSoftDeleted():
const result = await new QueryAggregate(OrderModel, req.query, { timezone })
.allowFields(['status', 'branchId', 'createdAt'])
.excludeSoftDeleted() // deletedAt: null, always applied
.whereIf(req.self.role !== 'admin', { branchId: req.self.linkedTo }) // branch staff see only their branch
.whereIf(req.query.vip === 'true', { tier: 'vip' })
.filter()
.sort()
.paginate();Populating a filter UI — distinct values + count, no data fetch:
const builder = new QueryAggregate(OrderModel, req.query, { timezone })
.allowFields(['status', 'branchId'])
.where({ branchId: req.self.linkedTo })
.filter();
const [statuses, total] = await Promise.all([
builder.clone().distinct('status'), // populate a <select> of possible statuses
builder.clone().count(), // "X results" label
]);Debug route — inspect the built pipeline / query plan without exposing it to normal users:
router.get('/orders/_debug', requireAdmin, async (req, res) => {
const builder = new QueryAggregate(OrderModel, req.query, { timezone })
.allowFields(['status', 'createdAt'])
.where({ branchId: req.self.linkedTo })
.range('createdAt', 'last7Days')
.filter()
.sort();
res.json({
pipeline: builder.debugPipeline(),
explain: await builder.explain('executionStats'),
});
});Cursor pagination on a non-default sort field:
const result = await new QueryAggregate(ProductModel, req.query, { timezone })
.allowFields(['price', 'name'])
.where({ inStock: true })
.filter()
.sort() // resolves ?sort=price into { price: 1 } — paginateCursor() will seek on "price"
.paginateCursor({
cursor: req.query.cursor as string | undefined,
limit: 24,
});Correlated sub-pipeline $lookup (needs let):
The built-in LookupOptions type doesn't expose let for correlated sub-pipelines. For that case, drop to .addStage() directly:
.addStage({
$lookup: {
from: 'orderItems',
let: { orderId: '$_id' },
pipeline: [
{ $match: { $expr: { $eq: ['$orderId', '$$orderId'] } } },
{ $project: { name: 1, qty: 1 } },
],
as: 'items',
},
})Sales analytics dashboard — join, group, and rolling window in one pipeline:
const timezone = (req.headers['x-timezone'] as string) ?? 'Asia/Dhaka';
const result = await new QueryAggregate(OrderModel, req.query, { timezone })
.allowFields(['branchId', 'status'])
.where({ status: 'PAID' })
.rangeLastNDays('createdAt', 30) // rolling 30-day window, client can't override
.filter()
.lookup({
from: 'branches',
as: 'branch',
localField: 'branchId',
foreignField: '_id',
single: true,
})
.addStage({
$group: {
_id: '$branchId',
branchName: { $first: '$branch.name' },
revenue: { $sum: '$total' },
orders: { $sum: 1 },
avgOrderValue: { $avg: '$total' },
},
})
.addStage({ $sort: { revenue: -1 } })
.paginate();Notification feed — lookup + unwind a single related document per row:
const result = await new QueryAggregate(NotificationModel, req.query, {
timezone,
})
.allowFields(['read', 'type', 'createdAt'])
.dateFields(['createdAt'])
.where({ userId: req.user.id })
.filter()
.lookup({
from: 'users',
as: 'actor',
localField: 'actorId',
foreignField: '_id',
single: true, // unwinds so `actor` is a single object, not an array
})
.sort()
.project('type read createdAt actor.name actor.avatar')
.paginate();Audit log search — global search + a bounded absolute datetime window:
const result = await new QueryAggregate(AuditLogModel, req.query, { timezone })
.allowFields(['action', 'actorId', 'createdAt'])
.dateFields(['createdAt'])
.where({ orgId: req.user.orgId })
.rangeCustom('createdAt', {
from: (req.query.from as string) ?? '2026-01-01T00:00:00',
to: (req.query.to as string) ?? new Date().toISOString(),
})
.filter()
.globalSearch(['action'])
.sort()
.project('action actorId createdAt')
.paginate();Multi-branch inventory report — objectIdFields + computeField + stats side by side:
const base = new QueryAggregate(ProductModel, req.query, { timezone })
.allowFields(['branchId', 'category', 'stock', 'price'])
.objectIdFields(['branchId'])
.where({ deletedAt: null })
.filter()
.computeField('stockValue', { $multiply: ['$stock', '$price'] })
.sort()
.project('branchId category stock price stockValue');
const [page, totals] = await Promise.all([
base.clone().paginate(),
base.clone().stats([
{ name: 'totalStockValue', op: 'sum', field: 'stockValue' },
{ name: 'lowStockAvg', op: 'avg', field: 'stock' },
{ name: 'skuCount', op: 'count' },
]),
]);
res.json({ ...page, totals });The final pipeline is always assembled in this fixed order:
$match (from .where() + .filter() + .range()/rangeXxx() + .globalSearch(), merged via $and)
↓
[.lookup() / .addStage() / .computeField() calls, in the order you called them]
↓
$project (from .project(), if set)
↓
$sort (from .sort())
↓
[terminal stage: $skip/$limit, $facet, keyset $match + $limit, or $count/$group — added internally by the method you call]
Implications:
- Anything added via
.addStage()/.computeField()runs after the base filter match and before projection/sort. - If you
$group/reshape documents, do it before.project()— or skip.project()and use.addStage({ $project: {...} })for full control over the new shape. - If you add your own
$sortvia.addStage()and also call.sort(), both stages are included (the builder's.sort()stage is always appended last in the base pipeline) — harmless but redundant. Prefer one or the other. .count(),.distinct(), and.stats()intentionally skip$project/$sort— they only need$match+ your.lookup()/.addStage()stages before their own terminal$count/$group..paginateCursor()builds its own$match/$sort/$limittail (the keyset condition), separate from.paginate()'s$facettail.
| Protection | Detail |
|---|---|
| NoSQL injection | Allowlist enforced on .filter()/.sort()/.project() via .allowFields() |
| Banned operators | $where, $expr, $function, $accumulator, $map, $reduce, $filter rejected recursively, any depth, in client input |
| Destructive stages | $out / $merge rejected in .addStage() / .lookup() sub-pipelines |
| ReDoS | .globalSearch() terms are regex-escaped before compilation |
| Oversized search | ?q= capped at 200 characters |
| Deep nesting DoS | Client filter object nesting capped at depth 5 (sanitize()) |
| Page size DoS | limit hard-capped at maxLimit (default 100), including in .paginateCursor() |
| Sort abuse | Sort fields hard-capped at 5 |
| Runaway queries | maxTimeMS applied to the aggregation; onSlowQuery for observability |
| Bad timezone input | Invalid IANA zone validated once at construction, falls back to default, never throws mid-query |
| Bad custom-range input | rangeCustomDay/rangeBetween/rangeLastNDays/rangeLastNHours/rangeHours/rangeCustom all throw QueryAggregateValidationError on malformed dates, non-positive counts, or invalid hour bounds — never silently produce a wrong range |
| Server condition override | .where() / .range() / every custom range method are merged last via $and — URL params can never override them |
| Tampered cursors | .paginateCursor() cursors are opaque base64url-encoded JSON; a malformed cursor throws QueryAggregateValidationError rather than silently misbehaving |
Untrusted .addStage() input |
Not sanitized/allowlisted by design — documented as a trusted, developer-authored-only escape hatch |
Important:
.addStage()and thepipelineoption in.lookup()are for server-defined stages only. They skip the sanitizer/allowlist/operator-rejection that protects.filter()/.where(). Never interpolate rawreq.query/req.bodyvalues into an.addStage()call without validating them yourself first.
import {
QueryAggregate,
QueryAggregateOptions,
QueryAggregateError,
QueryAggregateValidationError,
LookupOptions,
RangePreset,
SlowQueryInfo,
QueryParams,
PaginatedResult,
CustomDayInput,
CustomDateRangeInput,
CustomHourRangeInput,
CustomDateTimeRangeInput,
CursorPaginateOptions,
CursorPaginatedResult,
StatSpec,
} from 'mongoose-query-find';interface QueryAggregateOptions {
timezone?: string;
maxTimeMS?: number;
slowQueryThresholdMS?: number;
maxLimit?: number;
allowDiskUse?: boolean;
weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
onSlowQuery?: (info: SlowQueryInfo) => void;
onSanitizeDrop?: (path: string, value: unknown) => void;
}
interface LookupOptions {
from: string;
as: string;
localField?: string;
foreignField?: string;
pipeline?: PipelineStage[];
single?: boolean;
}
type RangePreset =
| 'today'
| 'yesterday'
| 'last7Days'
| 'last30Days'
| 'thisWeek'
| 'thisMonth';
interface SlowQueryInfo {
elapsedMs: number;
pipeline: PipelineStage[];
sort: Record<string, 1 | -1>;
page: number;
limit: number;
}
// ── Custom range types ──────────────────────────────────────────────────────
type CustomDayInput = string; // "YYYY-MM-DD"
interface CustomDateRangeInput {
from: string; // "YYYY-MM-DD"
to: string; // "YYYY-MM-DD"
}
interface CustomHourRangeInput {
date: string; // "YYYY-MM-DD"
fromHour: number; // 0–24
toHour: number; // 0–24, exclusive, must be > fromHour
}
interface CustomDateTimeRangeInput {
from: string; // "YYYY-MM-DD" | "YYYY-MM-DDTHH:mm:ss[.sss]" | offset-bearing ISO string
to: string;
}
// ── Advanced-feature types ──────────────────────────────────────────────────
interface CursorPaginateOptions {
cursor?: string;
limit?: number;
}
interface CursorPaginatedResult<T> {
data: T[];
limit: number;
nextCursor: string | null;
hasNextPage: boolean;
}
interface StatSpec {
name: string;
op: 'sum' | 'avg' | 'min' | 'max' | 'count';
field?: string;
}ISC © jsdev.robin@gmail.com
A purpose-built analytics/reporting layer for Mongoose (^8 || ^9), sitting alongside QueryFind and QueryAggregate. Where QueryAggregate is a general-purpose, client-input-safe pipeline builder, QueryAnalytics is a server-authored dashboard toolkit: time series, cohort retention, funnels, sessionization, percentiles, and more, each backed by a native MongoDB 7.0+ operator instead of a hand-rolled pipeline.
QueryAnalytics wraps Model.aggregate() with dashboard-shaped methods that return ready-to-chart data instead of raw pipeline rows:
timeSeries()/cumulativeSeries()— bucketed metrics over time, gap-filled, with a native running totalcompare()— period-over-period totals and % change for preset windows (today, this week, last 30 days, ...)breakdown()/rankedBreakdown()/multiBreakdown()— top-N by one or two dimensions, with an optional native dense rankfunnel()— step-by-step conversion countscohortRetention()— a full cohort-by-cohort retention matrixsessionize()— gap-based event sessionizationpercentiles()/stdDevOutliers()— native$percentile/$stdDevPopstatisticsmetrics()— several independent totals in a single round tripheatmap()— weekday × hour-of-day activity gridmovingAverage()/trend()/forecast()— pure-JS post-processing on a series you already fetchedwatch()— a live change-stream feed for dashboards that update in real timetoCSV()/toNDJSON()/dashboard()— export and batch helpers
It targets MongoDB 7.0+ and uses $setWindowFields, $denseRank, $shift, $stdDevPop, and $percentile directly — there's no fallback path for older servers, so calling cumulativeSeries(), rankedBreakdown(), sessionize(), percentiles(), or stdDevOutliers() against a pre-7.0 deployment will fail at the server, not in this library.
| Need | Use |
|---|---|
| Filtered/sorted/paginated list from client query params | QueryFind |
| Joins, grouping, or a client-driven filtered data page | QueryAggregate |
| A chart-ready time series with gaps filled in | QueryAnalytics |
| Week-over-week / month-over-month KPI comparisons | QueryAnalytics |
| Top-N breakdowns, ranked leaderboards, or a two-dimension pivot | QueryAnalytics |
| Signup → activation → purchase funnel counts | QueryAnalytics |
| "% of signups still active N weeks later" retention tables | QueryAnalytics |
| Turning a raw event stream into user sessions | QueryAnalytics |
| p50/p90/p99 latency or order-value percentiles | QueryAnalytics |
| Flagging statistical outliers for an anomalies widget | QueryAnalytics |
| A whole dashboard's worth of metrics in one call from your route | QueryAnalytics |
| Pushing live updates to a dashboard instead of polling | QueryAnalytics |
QueryAnalytics is not a client-input parser. Unlike QueryFind/QueryAggregate, it has no .filter(), no .allowFields(), and no operator-rejection pass over untrusted input — see Security below.
Ships from the same package as QueryFind and QueryAggregate:
npm install mongoose-query-findimport { QueryAnalytics } from 'mongoose-query-find';
import OrderModel from './models/order';
const qa = new QueryAnalytics(OrderModel, { timezone: 'Asia/Dhaka' })
.match({ branchId, status: { $ne: 'CANCELLED' } })
.dateRange('createdAt', { from: '2026-08-01', to: '2026-08-31' });
const series = await qa.timeSeries({
dateField: 'createdAt',
interval: 'day',
metrics: [
{ name: 'revenue', op: 'sum', field: 'total' },
{ name: 'orders', op: 'count' },
],
});series is a gap-filled array ready to hand straight to a charting library:
[
{ "bucket": "2026-08-01T00:00:00.000Z", "revenue": 4820, "orders": 61 },
{ "bucket": "2026-08-02T00:00:00.000Z", "revenue": 0, "orders": 0 },
{ "bucket": "2026-08-03T00:00:00.000Z", "revenue": 5310, "orders": 74 }
]new QueryAnalytics<T>(model, options?);| Parameter | Type | Description |
|---|---|---|
model |
Model<T> |
A Mongoose model |
options |
QueryAnalyticsOptions |
Configuration, all optional |
| Option | Type | Default | Description |
|---|---|---|---|
timezone |
string |
'Asia/Dhaka' |
IANA timezone for bucketing and range math. Invalid values are validated once at construction and silently fall back to the default. |
maxTimeMS |
number |
8000 |
Max time MongoDB may spend per query. Higher than QueryFind/QueryAggregate's default since analytics pipelines tend to be heavier. 0 disables. |
allowDiskUse |
boolean |
false |
Forwarded to .allowDiskUse() for large $group/$setWindowFields stages. |
weekStartsOn |
0–6 |
6 (Saturday) |
Used by weekly bucketing (interval: 'week'), compare({ preset: 'thisWeek' }), and cohortRetention(). |
cacheTTLMs |
number |
0 |
TTL in ms for the built-in in-memory cache. 0 disables caching. See note below. |
Note on
cacheTTLMs: the class ships a small in-memory TTL cache (cacheGet/cacheSet) and aQueryAnalytics.clearCache()static, but as of this release none of the public methods (timeSeries,breakdown,metrics, etc.) pass a cache key into it — so settingcacheTTLMsdoesn't yet cache anything on its own. Treat it as reserved for a future release; if you need caching today, memoize at the call site (e.g. wrapdashboard()calls in your own short-lived cache).
Merges hard, developer-authored conditions into the base $match. Call as many times as needed — later calls merge over earlier ones on the same key via Object.assign.
qa.match({ branchId, status: { $ne: 'CANCELLED' } });Because QueryAnalytics has no .filter(), match() is the only way conditions enter the pipeline besides .dateRange() and .addStage() — there's no allowlist because there's no untrusted input path. Don't pass raw req.query into it directly; validate/whitelist any client-influenced value yourself first (see Security).
Hard, inclusive local-date range condition on field, converted to a UTC $gte/$lt window using the instance's timezone.
qa.dateRange('createdAt', { from: '2026-08-01', to: '2026-08-31' });Throws QueryAnalyticsError if from/to aren't YYYY-MM-DD.
Escape hatch for extra developer-authored stages, spliced in right after the base $match, before any analytics-specific stages. Blocks $out/$merge recursively, at any nesting depth.
qa.addStage({ $addFields: { margin: { $subtract: ['$price', '$cost'] } } });Copies the current filter state (match()/dateRange() conditions and addStage() stages) into a new, independent QueryAnalytics instance sharing the same options. Handy for branching one filtered base into several analytics calls without rebuilding the filter each time — see Recipes.
Buckets documents by dateField into timezone-aligned interval buckets ('hour' | 'day' | 'week' | 'month') via $dateTrunc, computes metrics per bucket, and — by default — fills empty buckets with zero values so a chart never shows a broken line for a quiet day.
interface StatSpec {
name: string;
op: 'sum' | 'avg' | 'min' | 'max' | 'count' | 'distinctCount';
field?: string; // required for every op except 'count'
}
await qa.timeSeries({
dateField: 'createdAt',
interval: 'day',
metrics: [
{ name: 'revenue', op: 'sum', field: 'total' },
{ name: 'orders', op: 'count' },
{ name: 'customers', op: 'distinctCount', field: 'customerId' },
],
fillGaps: true, // default
});interval: 'week' respects the constructor's weekStartsOn. Set fillGaps: false to get back only buckets that actually had data.
Same bucketing as timeSeries(), plus a native running-total column per metric (${name}_cumulative), computed server-side via $setWindowFields — no client-side accumulation pass.
await qa.cumulativeSeries({
dateField: 'createdAt',
interval: 'day',
metrics: [{ name: 'revenue', op: 'sum', field: 'total' }],
});
// [{ bucket: '...', revenue: 420, revenue_cumulative: 420 }, { bucket: '...', revenue: 310, revenue_cumulative: 730 }, ...]distinctCount metrics are skipped for the cumulative column — a running distinct-count isn't a well-defined windowed sum. Unlike timeSeries(), this method has no fillGaps option.
Computes metrics for a preset current period and the equivalent prior period, plus absolute and percentage change.
interface CompareResult {
current: Record<string, number>;
previous: Record<string, number>;
change: Record<string, number>; // absolute delta
changePct: Record<string, number | null>; // null when previous was 0
}
const result = await qa.compare({
dateField: 'createdAt',
metrics: [{ name: 'revenue', op: 'sum', field: 'total' }],
preset: 'last7Days',
});Presets: 'today' | 'thisWeek' | 'thisMonth' | 'last7Days' | 'last30Days'. 'thisWeek'/'thisMonth' compare against a prior period of the same length (e.g. 12 days into this month vs. the last 12 days of last month), and 'thisWeek' respects weekStartsOn.
Any
.dateRange()you've already applied on the instance is ignored here — the preset defines both windows on its own, independent of the instance's filter state..match()conditions still apply to both windows.
Groups by field, computes metrics per group, sorted and capped.
await qa.breakdown(
'category',
[{ name: 'revenue', op: 'sum', field: 'total' }],
{
limit: 10, // default 10
sortBy: 'revenue', // default: metrics[0].name
sortDir: -1, // default -1
},
);
// [{ key: 'Electronics', revenue: 18400 }, { key: 'Home', revenue: 9210 }, ...]Same as breakdown(), but adds a native 1-based dense-rank column via $setWindowFields/$denseRank — no client-side re-sorting needed for a leaderboard UI.
await qa.rankedBreakdown('salesRepId', [
{ name: 'revenue', op: 'sum', field: 'total' },
]);
// [{ key: 'rep_1', rank: 1, revenue: 18400 }, { key: 'rep_2', rank: 2, revenue: 18400 }, { key: 'rep_3', rank: 4, revenue: 9210 }, ...]Ties share a rank and the next distinct value skips ahead (standard dense-rank semantics), so two reps tied for first both show rank: 1 and the next lowest gets rank: 2.
Pivot table: groups by two dimensions at once, one row per (dimA, dimB) pair.
await qa.multiBreakdown(
['branchId', 'status'],
[{ name: 'orders', op: 'count' }],
{
limit: 200, // optional; no cap by default
},
);
// [{ key: 'branch_a', key2: 'PAID', orders: 40 }, { key: 'branch_a', key2: 'REFUNDED', orders: 3 }, ...]Rows are sorted by key then key2 ascending. There's no default limit — pass one for large dimension combinations.
Counts how many base documents match each step's condition, independently, in parallel.
const result = await qa.funnel([
{ name: 'Visited', match: {} },
{ name: 'Signed up', match: { signedUpAt: { $ne: null } } },
{ name: 'Purchased', match: { firstOrderAt: { $ne: null } } },
]);interface FunnelResult {
name: string;
count: number;
conversionFromPrevious: number | null; // %
conversionFromStart: number | null; // %
}Each step's match is ANDed with the instance's base filter — steps are independent counts, not automatically cumulative. If step 3 should imply step 2, include that condition in step 3's match yourself. conversionFromPrevious/conversionFromStart are null for the first step, and null (instead of Infinity/NaN) whenever the denominator is 0.
Buckets entities into cohorts by the first occurrence of cohortDateField, then measures how many of each cohort had activity (activityDateField) in each subsequent period.
interface CohortRetentionOptions {
cohortDateField: string; // e.g. signup date
activityDateField: string; // e.g. any return-visit date (can equal cohortDateField)
idField: string; // e.g. userId
cohortInterval: 'hour' | 'day' | 'week' | 'month';
periods: number; // how many periods forward to measure
}
const table = await qa.cohortRetention({
cohortDateField: 'signupAt',
activityDateField: 'lastActiveAt',
idField: 'userId',
cohortInterval: 'week',
periods: 8,
});interface CohortRow {
cohort: string; // ISO bucket start
cohortSize: number;
retention: (number | null)[]; // index 0 = period 0
retentionPct: (number | null)[];
}Two aggregate calls run first (cohort assignment, then distinct activity buckets), and the retention matrix itself is assembled in JS afterward. Notes:
retention[0](period 0) falls back to the fullcohortSizewhen there's no explicit activity row recorded at offset 0 — useful when the cohort event and the first activity event are the same document, but worth checking if your two date fields can genuinely diverge at period 0.- For
cohortInterval: 'month', offsets are computed against an approximate 30-day month for period-bucketing math; the cohort buckets themselves are exact (via$dateTrunc), only the offset-from-cohort calculation is approximate.
Groups per-user events into sessions using native $setWindowFields + $shift gap detection — no self-lookup or JS grouping pass.
interface SessionizeOptions {
userField: string;
dateField: string;
gapMinutes: number; // idle gap after which a new session starts
}
const sessions = await qa.sessionize({
userField: 'userId',
dateField: 'eventAt',
gapMinutes: 30,
});interface SessionSummary {
userId: unknown;
sessionStart: string;
sessionEnd: string;
eventCount: number;
durationSeconds: number;
}Percentiles (0–100) of field over the current filter, via native $percentile (method: 'approximate').
await qa.percentiles('total', [50, 90, 99]);
// { p50: 42, p90: 118, p99: 340 }Values are null when there's no matching data.
Flags documents whose field value is more than threshold standard deviations from the mean, using native $stdDevPop.
await qa.stdDevOutliers('total', { threshold: 3, limit: 50 }); // defaults showninterface OutlierRow {
_id: unknown;
value: number;
zScore: number;
[key: string]: unknown; // full source document is spread in
}Two round trips: one to compute mean/stdDev over the full filtered set, one to fetch flagged rows. Returns [] if there's no data or the standard deviation is 0 (constant field).
Caveat: rows are sorted by
zScoredescending beforelimitis applied, so with a tightlimiton a field that has outliers on both tails, strongly positive outliers can crowd out strongly negative ones. Raiselimit(or drop the$limitand sort byMath.abs(zScore)yourself) if you need both tails represented fairly.
Several independent StatSpec metrics in one round trip — cheaper than N separate calls for a KPI-strip widget.
await qa.metrics([
{ name: 'revenue', op: 'sum', field: 'total' },
{ name: 'avgOrder', op: 'avg', field: 'total' },
{ name: 'orders', op: 'count' },
]);
// { revenue: 48213, avgOrder: 62.1, orders: 776 }These run entirely in JS against a series you've already fetched — no database call.
Adds a trailing moving-average field to an existing time series.
QueryAnalytics.movingAverage(series, 'revenue', 7); // adds `revenue_ma`The window shrinks near the start of the series rather than looking backward past index 0 (so the first point's average is just itself, the second is the average of the first two, and so on). Throws QueryAnalyticsError if windowSize < 1.
Simple up/down/flat classification, comparing the average of the last n points to the n before that.
QueryAnalytics.trend(series, 'revenue', 3, 2); // n=3, flatThresholdPct=2 (defaults)
// 'up' | 'down' | 'flat' | 'insufficient_data'Returns 'insufficient_data' when the series has fewer than 2 * n points.
A simple linear-regression projection for periodsAhead future buckets, based on the series' existing trend line.
QueryAnalytics.forecast(series, 'revenue', 7);
// [{ index: 30, predicted: 512.4 }, { index: 31, predicted: 518.9 }, ...]Good enough for a dashed projection line on a chart — not a substitute for a real forecasting model. Returns [] for series shorter than 2 points.
Counts (or sums valueField, if given) by weekday (0=Sun..6=Sat, local timezone) × hour-of-day (0–23, local timezone).
await qa.heatmap('createdAt');
// [{ weekday: 0, hour: 0, value: 4 }, { weekday: 0, hour: 1, value: 1 }, ...]
await qa.heatmap('createdAt', 'total'); // sum revenue instead of counting docsReturns a flat array (one row per weekday/hour combination that occurred), not a pre-filled 7×24 matrix — fill in the zero cells client-side if your chart needs a dense grid.
Subscribes to a MongoDB change stream on the model's collection and invokes onChange for every matching insert/update/delete/replace — handy for pushing live dashboard updates over a websocket instead of polling.
const stream = qa.watch(
(change) => io.emit('order:change', change),
{ operationTypes: ['insert', 'update'] }, // optional; all types by default
);
// later
stream.close();Returns the underlying ChangeStream — the caller owns its lifecycle and must .close() it. fullDocument: 'updateLookup' is set so update events include the full document.
Two things worth knowing:
- Only conditions set via
.match()/.dateRange()are translated onto the stream (asfullDocument.<field>equality conditions) — stages added via.addStage()aren't part of the change-stream pipeline. - Change streams require a MongoDB replica set or Atlas cluster; they don't work against a standalone
mongod.
Flatten an array of plain-object rows (e.g. the output of timeSeries()/breakdown()) into CSV or newline-delimited JSON.
QueryAnalytics.toCSV(rows); // header row inferred from the union of all row keys; values with , " or \n are quoted
QueryAnalytics.toNDJSON(rows); // one JSON.stringify(row) per line — friendlier for streaming large exportsRuns several named analytics calls in parallel and returns them keyed by name — one round trip from the caller's perspective (though each named task is still its own aggregate call under the hood).
const data = await QueryAnalytics.dashboard({
revenue: () =>
qa.timeSeries({
dateField: 'createdAt',
interval: 'day',
metrics: [{ name: 'revenue', op: 'sum', field: 'total' }],
}),
topCategories: () =>
qa.breakdown('category', [{ name: 'revenue', op: 'sum', field: 'total' }]),
totals: () => qa.metrics([{ name: 'orders', op: 'count' }]),
});
// { revenue: [...], topCategories: [...], totals: { orders: 776 } }Clears the shared in-memory cache. Process-wide — not scoped to one instance or one model. (See the caching note under Constructor & Options: as of this release, nothing populates the cache yet via the public API.)
Branch dashboard in one round trip:
const qa = new QueryAnalytics(OrderModel, { timezone })
.match({ branchId: req.self.linkedTo, status: { $ne: 'CANCELLED' } })
.dateRange('createdAt', {
from: req.query.from as string,
to: req.query.to as string,
});
const dashboard = await QueryAnalytics.dashboard({
series: () =>
qa.timeSeries({
dateField: 'createdAt',
interval: 'day',
metrics: [
{ name: 'revenue', op: 'sum', field: 'total' },
{ name: 'orders', op: 'count' },
],
}),
topProducts: () =>
qa.breakdown(
'productId',
[{ name: 'revenue', op: 'sum', field: 'total' }],
{ limit: 5 },
),
kpis: () =>
qa.metrics([
{ name: 'revenue', op: 'sum', field: 'total' },
{ name: 'orders', op: 'count' },
]),
});
res.json(dashboard);Week-over-week KPI strip:
const wow = await new QueryAnalytics(OrderModel, { timezone })
.match({ branchId: req.self.linkedTo })
.compare({
dateField: 'createdAt',
preset: 'thisWeek',
metrics: [
{ name: 'revenue', op: 'sum', field: 'total' },
{ name: 'orders', op: 'count' },
],
});
res.json(wow); // { current, previous, change, changePct }Signup retention curve, branched off one base filter:
const base = new QueryAnalytics(UserModel, { timezone }).match({
orgId: req.user.orgId,
});
const [retention, funnel] = await Promise.all([
base.clone().cohortRetention({
cohortDateField: 'signupAt',
activityDateField: 'lastActiveAt',
idField: '_id',
cohortInterval: 'week',
periods: 8,
}),
base.clone().funnel([
{ name: 'Signed up', match: {} },
{ name: 'Activated', match: { activatedAt: { $ne: null } } },
{ name: 'Subscribed', match: { subscribedAt: { $ne: null } } },
]),
]);Anomalies widget with a projected trend line:
const qa = new QueryAnalytics(OrderModel, { timezone }).match({ branchId });
const series = await qa.timeSeries({
dateField: 'createdAt',
interval: 'day',
metrics: [{ name: 'revenue', op: 'sum', field: 'total' }],
});
const withMA = QueryAnalytics.movingAverage(series, 'revenue', 7);
const trend = QueryAnalytics.trend(series, 'revenue');
const projection = QueryAnalytics.forecast(series, 'revenue', 7);
const outliers = await qa.stdDevOutliers('total', { threshold: 3 });
res.json({ series: withMA, trend, projection, outliers });Live order feed alongside a polled summary:
const qa = new QueryAnalytics(OrderModel, { timezone }).match({ branchId });
const stream = qa.watch(
(change) => io.to(`branch:${branchId}`).emit('order:change', change),
{
operationTypes: ['insert'],
},
);
req.on('close', () => stream.close());| Protection | Detail |
|---|---|
| No client-input path | QueryAnalytics has no .filter()/.allowFields() — every condition comes from .match(), .dateRange(), or .addStage(), all developer-authored. Don't pass raw req.query/req.body into any of them without validating it yourself first. |
| Destructive stages | $out/$merge are rejected recursively, at any nesting depth, in .addStage(). |
| Strict date parsing | .dateRange() requires YYYY-MM-DD and throws QueryAnalyticsError on anything else — no silent misinterpretation of ambiguous date strings. |
| Runaway queries | maxTimeMS (default 8000) is applied to every aggregation. |
| Bad timezone input | An invalid IANA timezone is validated once at construction and silently falls back to the default, rather than throwing mid-query. |
| MongoDB version requirement | cumulativeSeries(), rankedBreakdown(), sessionize(), percentiles(), and stdDevOutliers() require MongoDB 7.0+; there's no feature-detection or fallback, so calling them against an older server surfaces as a server-side aggregation error. |
| Change-stream requirements | .watch() requires a replica set or Atlas cluster and only forwards .match()/.dateRange() conditions onto the stream, not .addStage() stages. |
Important: because there's no allowlist here, field names passed to
breakdown(),multiBreakdown(),heatmap(), etc. should come from your own code or a whitelist you maintain — not directly from a query string — the same way you'd treat any other server-authored aggregation field.
import {
QueryAnalytics,
QueryAnalyticsOptions,
QueryAnalyticsError,
Interval,
ComparePreset,
StatSpec,
TimeSeriesPoint,
CompareResult,
BreakdownRow,
RankedBreakdownRow,
FunnelStep,
FunnelResult,
CohortRetentionOptions,
CohortRow,
SessionizeOptions,
SessionSummary,
OutlierRow,
} from 'mongoose-query-find';type Interval = 'hour' | 'day' | 'week' | 'month';
type ComparePreset =
| 'today'
| 'thisWeek'
| 'thisMonth'
| 'last7Days'
| 'last30Days';
interface StatSpec {
name: string;
op: 'sum' | 'avg' | 'min' | 'max' | 'count' | 'distinctCount';
field?: string; // required for every op except 'count'
}
interface QueryAnalyticsOptions {
timezone?: string;
maxTimeMS?: number;
allowDiskUse?: boolean;
weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
cacheTTLMs?: number;
}
interface TimeSeriesPoint {
bucket: string; // ISO string, local-boundary-aligned
[metric: string]: number | string;
}
interface CompareResult {
current: Record<string, number>;
previous: Record<string, number>;
change: Record<string, number>;
changePct: Record<string, number | null>;
}
interface BreakdownRow {
key: unknown;
[metric: string]: unknown;
}
interface RankedBreakdownRow extends BreakdownRow {
rank: number;
}
interface FunnelStep {
name: string;
match: Record<string, unknown>;
}
interface FunnelResult {
name: string;
count: number;
conversionFromPrevious: number | null;
conversionFromStart: number | null;
}
interface CohortRetentionOptions {
cohortDateField: string;
activityDateField: string;
idField: string;
cohortInterval: Interval;
periods: number;
}
interface CohortRow {
cohort: string;
cohortSize: number;
retention: (number | null)[];
retentionPct: (number | null)[];
}
interface SessionizeOptions {
userField: string;
dateField: string;
gapMinutes: number;
}
interface SessionSummary {
userId: unknown;
sessionStart: string;
sessionEnd: string;
eventCount: number;
durationSeconds: number;
}
interface OutlierRow {
_id: unknown;
value: number;
zScore: number;
[key: string]: unknown;
}ISC © jsdev.robin@gmail.com