@classytic/mongokit 3.38.1 → 3.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{PaginationEngine-CBS1XGzp.d.mts → PaginationEngine-D-P19Y9m.d.mts} +34 -9
- package/dist/PaginationEngine-Dvl8jcRY.mjs +1198 -0
- package/dist/{Repository-Ck5x43wF.mjs → Repository-BMgPSkqZ.mjs} +672 -655
- package/dist/{Repository-BdSkQ8vc.d.mts → Repository-CR8Y9g2E.d.mts} +106 -34
- package/dist/actions/index.d.mts +1 -1
- package/dist/actions/index.mjs +1 -1
- package/dist/adapter/index.d.mts +21 -0
- package/dist/adapter/index.mjs +1 -1
- package/dist/{adapter-Bfqz8yHE.mjs → adapter-BrVfPFSW.mjs} +45 -1
- package/dist/ai/index.d.mts +1 -1
- package/dist/better-auth/index.mjs +2 -2
- package/dist/{create-repository-B5EulQ31.mjs → create-repository-BzBUMvd1.mjs} +3 -3
- package/dist/{create-repository-BU4x9Kb0.d.mts → create-repository-CAle1MSU.d.mts} +5 -5
- package/dist/idempotency/index.d.mts +23 -0
- package/dist/idempotency/index.mjs +134 -0
- package/dist/{index-DKEql4cL.d.mts → index-CmjNyulu.d.mts} +67 -8
- package/dist/index.d.mts +125 -35
- package/dist/index.mjs +286 -676
- package/dist/indexes/verify-indexes.d.mts +14 -1
- package/dist/indexes/verify-indexes.mjs +54 -7
- package/dist/{method-registry.plugin-DblM7V6j.mjs → method-registry.plugin-C63pGn1d.mjs} +54 -4
- package/dist/{multi-tenant.plugin-BwSvTirB.d.mts → multi-tenant.plugin-CBgCo9im.d.mts} +77 -5
- package/dist/{operations-DZodvjC-.d.mts → operations-CrRIoc8Z.d.mts} +22 -7
- package/dist/outbox/index.d.mts +1 -1
- package/dist/outbox/index.mjs +7 -5
- package/dist/pagination/PaginationEngine.d.mts +1 -1
- package/dist/pagination/PaginationEngine.mjs +1 -1
- package/dist/pagination/utils/limits.d.mts +1 -1
- package/dist/pagination/utils/limits.mjs +22 -1
- package/dist/pagination-BpIv2bdS.d.mts +364 -0
- package/dist/{plugin-options-DXnlKM3B.d.mts → plugin-options-Zk46lD6F.d.mts} +16 -2
- package/dist/plugins/index.d.mts +8 -3
- package/dist/plugins/index.mjs +99 -26
- package/dist/{repository-H_ffGgpE.d.mts → repository-odl03582.d.mts} +50 -6
- package/dist/sync/index.d.mts +40 -0
- package/dist/sync/index.mjs +334 -0
- package/dist/tenant/index.d.mts +89 -0
- package/dist/tenant/index.mjs +96 -0
- package/dist/testkit/index.d.mts +2 -2
- package/dist/testkit/index.mjs +1 -1
- package/dist/{timestamp.plugin-CBQukQzV.mjs → timestamp.plugin-DF0gJB6p.mjs} +116 -9
- package/dist/{to-plain-Dyz_qvuo.d.mts → to-plain-k7SjNlSS.d.mts} +2 -2
- package/dist/transaction-B5L9glO0.mjs +533 -0
- package/dist/{update-jSXp3nu3.mjs → update-DG0fCwRJ.mjs} +293 -83
- package/dist/utils/index.d.mts +1 -1
- package/dist/{validation-chain.plugin-DhNyxNCw.mjs → validation-chain.plugin-BLY2gLEk.mjs} +192 -279
- package/dist/{validation-chain.plugin-yQRob3Av.d.mts → validation-chain.plugin-DYp9E7K2.d.mts} +26 -20
- package/package.json +17 -4
- package/dist/PaginationEngine-BX9o_-4R.mjs +0 -735
- package/dist/core-62oo3N1C.d.mts +0 -50
- package/dist/pagination-jSUdrACK.d.mts +0 -158
|
@@ -0,0 +1,1198 @@
|
|
|
1
|
+
import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.mjs";
|
|
2
|
+
import { n as warn } from "./logger-K7dPmu2U.mjs";
|
|
3
|
+
import { t as createError } from "./error-BuByZwKU.mjs";
|
|
4
|
+
import { calculateSkip, calculateTotalPages, shouldWarnDeepPagination, validateLimit, validatePage } from "./pagination/utils/limits.mjs";
|
|
5
|
+
import mongoose from "mongoose";
|
|
6
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
7
|
+
//#region src/pagination/utils/cursor-signing.ts
|
|
8
|
+
/**
|
|
9
|
+
* HMAC signing for pagination cursors.
|
|
10
|
+
*
|
|
11
|
+
* ## What this protects, and what it does not
|
|
12
|
+
*
|
|
13
|
+
* A cursor is a POSITION, and the server's own filters decide what a caller may
|
|
14
|
+
* read — so a stolen or shared cursor cannot reach another tenant's rows, and
|
|
15
|
+
* signing is not a substitute for scoping. What an unsigned cursor gives away
|
|
16
|
+
* is the ability to MOVE the position: edit the encoded sort value, re-submit,
|
|
17
|
+
* and the response says whether any row sorts past it. Repeat, and that is a
|
|
18
|
+
* binary search over a column the caller can never read directly — "where do
|
|
19
|
+
* salaries above X start", against a list that only ever showed them names.
|
|
20
|
+
*
|
|
21
|
+
* Signing closes that. It does NOT hide the cursor's contents: the payload is
|
|
22
|
+
* still base64url JSON and still readable by anyone holding the token, field
|
|
23
|
+
* names included. **Signing is integrity, not confidentiality.** A deployment
|
|
24
|
+
* that must not reveal which column it sorts by needs encryption, which this is
|
|
25
|
+
* not.
|
|
26
|
+
*
|
|
27
|
+
* ## The rules that make it worth having
|
|
28
|
+
*
|
|
29
|
+
* - **A configured secret makes a signature REQUIRED.** Accepting an unsigned
|
|
30
|
+
* token "for compatibility" means an attacker strips the signature and the
|
|
31
|
+
* protection evaporates while still looking enabled — the exact shape of a
|
|
32
|
+
* specific instruction defeated by a general default.
|
|
33
|
+
* - **An unusable secret THROWS.** `cursorSecret: ''` (an unset env var read
|
|
34
|
+
* without a check) would otherwise mean "signing on, key guessable", and
|
|
35
|
+
* nothing would say so.
|
|
36
|
+
* - **Comparison is timing-safe.** `===` on a signature leaks it byte by byte
|
|
37
|
+
* to anyone willing to time the responses.
|
|
38
|
+
* - **Rotation takes a list.** Sign with the first, accept any — otherwise
|
|
39
|
+
* changing the key invalidates every cursor a client is holding.
|
|
40
|
+
*/
|
|
41
|
+
/**
|
|
42
|
+
* Separator between payload and signature.
|
|
43
|
+
*
|
|
44
|
+
* `.` is not in the base64url alphabet, so it cannot appear inside either half
|
|
45
|
+
* — the split is unambiguous without escaping, and the whole token stays
|
|
46
|
+
* URL-safe.
|
|
47
|
+
*/
|
|
48
|
+
const SEPARATOR = ".";
|
|
49
|
+
/**
|
|
50
|
+
* A key shorter than this is not a key. Chosen to match the HMAC-SHA256 block
|
|
51
|
+
* consideration loosely, but the real point is to reject `''`, `'secret'` and
|
|
52
|
+
* `'changeme'` — the values that appear when an env var was never set.
|
|
53
|
+
*/
|
|
54
|
+
const MIN_SECRET_LENGTH = 16;
|
|
55
|
+
/**
|
|
56
|
+
* Normalise a configured secret into the keys that sign and verify.
|
|
57
|
+
*
|
|
58
|
+
* Throws rather than falling back: every failure mode here — an empty string, a
|
|
59
|
+
* list of empty strings, a two-character placeholder — describes a deployment
|
|
60
|
+
* that BELIEVES it is signing cursors. Returning "unsigned" quietly is how it
|
|
61
|
+
* would keep believing that.
|
|
62
|
+
*/
|
|
63
|
+
function resolveCursorSecrets(secret) {
|
|
64
|
+
if (secret === void 0 || secret === null) return void 0;
|
|
65
|
+
const keys = (Array.isArray(secret) ? secret : [secret]).map((k) => String(k ?? ""));
|
|
66
|
+
if (keys.length === 0) throw new Error("cursorSecret: an empty list cannot sign anything — omit it, or supply a key");
|
|
67
|
+
const tooShort = keys.find((k) => k.trim().length < MIN_SECRET_LENGTH);
|
|
68
|
+
if (tooShort !== void 0) throw new Error(`cursorSecret: every key must be at least ${MIN_SECRET_LENGTH} characters (got one of length ${tooShort.trim().length}). An unset environment variable reads as an empty string, which would sign every cursor with a key anyone can guess.`);
|
|
69
|
+
return keys;
|
|
70
|
+
}
|
|
71
|
+
/** The raw HMAC of a payload under one key, base64url. */
|
|
72
|
+
function sign(payload, key) {
|
|
73
|
+
return createHmac("sha256", key).update(payload, "utf8").digest("base64url");
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Append a signature to an encoded payload.
|
|
77
|
+
*
|
|
78
|
+
* Returns the payload unchanged when no secret is configured, so a deployment
|
|
79
|
+
* that does not sign is byte-for-byte unaffected.
|
|
80
|
+
*/
|
|
81
|
+
function attachSignature(payload, secrets) {
|
|
82
|
+
if (!secrets) return payload;
|
|
83
|
+
return payload + SEPARATOR + sign(payload, secrets[0]);
|
|
84
|
+
}
|
|
85
|
+
/** Split a token into its payload and signature halves. */
|
|
86
|
+
function split(token) {
|
|
87
|
+
const at = token.lastIndexOf(SEPARATOR);
|
|
88
|
+
if (at === -1) return {
|
|
89
|
+
payload: token,
|
|
90
|
+
signature: void 0
|
|
91
|
+
};
|
|
92
|
+
return {
|
|
93
|
+
payload: token.slice(0, at),
|
|
94
|
+
signature: token.slice(at + 1)
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/** Constant-time equality. `timingSafeEqual` throws on a length mismatch, which is itself a leak-free "no". */
|
|
98
|
+
function matches(a, b) {
|
|
99
|
+
const left = Buffer.from(a, "utf8");
|
|
100
|
+
const right = Buffer.from(b, "utf8");
|
|
101
|
+
if (left.length !== right.length) return false;
|
|
102
|
+
return timingSafeEqual(left, right);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Verify a token and return the payload to decode.
|
|
106
|
+
*
|
|
107
|
+
* - **No secret configured** → any signature present is stripped and ignored.
|
|
108
|
+
* Turning signing off must not reject the tokens issued while it was on, or
|
|
109
|
+
* a rollback breaks every client mid-page.
|
|
110
|
+
* - **Secret configured** → a valid signature is mandatory. A missing one and a
|
|
111
|
+
* wrong one are reported differently ON PURPOSE: they mean different things to
|
|
112
|
+
* whoever is debugging (a config mismatch versus a rotated key or a tampered
|
|
113
|
+
* token), and neither message helps anyone forge one.
|
|
114
|
+
*/
|
|
115
|
+
function verifySignature(token, secrets) {
|
|
116
|
+
const { payload, signature } = split(token);
|
|
117
|
+
if (!secrets) return payload;
|
|
118
|
+
if (signature === void 0) throw new Error("Invalid cursor token: this deployment signs cursors and this one carries no signature");
|
|
119
|
+
for (const key of secrets) if (matches(signature, sign(payload, key))) return payload;
|
|
120
|
+
throw new Error("Invalid cursor token: signature does not verify (tampered, or issued under a retired key)");
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Whether cursor signing is switched on. Callers use it to refuse the shapes
|
|
124
|
+
* that BYPASS signing rather than break it — a bare ObjectId is a perfectly
|
|
125
|
+
* valid position that carries no signature at all.
|
|
126
|
+
*/
|
|
127
|
+
function signingRequired(secrets) {
|
|
128
|
+
return secrets !== void 0;
|
|
129
|
+
}
|
|
130
|
+
//#endregion
|
|
131
|
+
//#region src/pagination/defaults.ts
|
|
132
|
+
/**
|
|
133
|
+
* The policy lives on `globalThis` under a registered symbol, NOT in a module
|
|
134
|
+
* `let`.
|
|
135
|
+
*
|
|
136
|
+
* A module-level binding is per MODULE INSTANCE, and this package can appear
|
|
137
|
+
* twice in one dependency graph — an engine pinning `^3.38` beside a host on
|
|
138
|
+
* `^3.39`, a pnpm layout that duplicates on a peer, an ESM/CJS dual load. The
|
|
139
|
+
* host would call `configurePaginationDefaults` on its copy while the
|
|
140
|
+
* repositories an engine built read the other copy's empty object, and the
|
|
141
|
+
* feature would silently do nothing in EXACTLY the composition it exists for:
|
|
142
|
+
* a host that does not construct its own repositories.
|
|
143
|
+
*
|
|
144
|
+
* `Symbol.for` is the same mechanism, for the same reason, as arc's
|
|
145
|
+
* `Symbol.for('arc.runtimeCapabilities')` / `ARC_EVENT_TRANSPORT`.
|
|
146
|
+
*
|
|
147
|
+
* Per realm, not per process: a `worker_threads` worker has its own
|
|
148
|
+
* `globalThis`, so each worker configures itself at its own boot.
|
|
149
|
+
*/
|
|
150
|
+
const POLICY_SLOT = Symbol.for("classytic.mongokit.paginationDefaults");
|
|
151
|
+
function policy() {
|
|
152
|
+
const holder = globalThis;
|
|
153
|
+
holder[POLICY_SLOT] ??= {};
|
|
154
|
+
return holder[POLICY_SLOT];
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Set the deployment's pagination policy. Merges into whatever is already set,
|
|
158
|
+
* so two call sites configuring different keys do not clobber each other.
|
|
159
|
+
*
|
|
160
|
+
* Call it at boot, before composing modules. Ordering is not load-bearing —
|
|
161
|
+
* repositories read this at query time, not construction time — but a call
|
|
162
|
+
* made after traffic starts changes behaviour mid-flight, which is rarely what
|
|
163
|
+
* anyone means.
|
|
164
|
+
*/
|
|
165
|
+
function configurePaginationDefaults(defaults) {
|
|
166
|
+
Object.assign(policy(), stripUndefined(defaults));
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* The current deployment policy. Empty until something configures it.
|
|
170
|
+
*
|
|
171
|
+
* A COPY — `Readonly<T>` is erased at runtime, and handing out the live object
|
|
172
|
+
* would let a caller mutate the policy through a getter that promised not to.
|
|
173
|
+
*/
|
|
174
|
+
function getPaginationDefaults() {
|
|
175
|
+
return { ...policy() };
|
|
176
|
+
}
|
|
177
|
+
/** Back to library defaults. For tests — a global that cannot be reset is a leak between them. */
|
|
178
|
+
function resetPaginationDefaults() {
|
|
179
|
+
const current = policy();
|
|
180
|
+
for (const key of Object.keys(current)) delete current[key];
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* An explicitly-passed `undefined` means "not specified", not "unset the
|
|
184
|
+
* deployment policy" — `{ defaultMode: undefined }` built from an optional
|
|
185
|
+
* host env var must not silently clear what another call configured.
|
|
186
|
+
*/
|
|
187
|
+
function stripUndefined(input) {
|
|
188
|
+
const out = {};
|
|
189
|
+
if (input.defaultCountStrategy !== void 0) out.defaultCountStrategy = input.defaultCountStrategy;
|
|
190
|
+
if (input.defaultCountLimit !== void 0) out.defaultCountLimit = input.defaultCountLimit;
|
|
191
|
+
if (input.cursorSecret !== void 0) out.cursorSecret = input.cursorSecret;
|
|
192
|
+
if (input.defaultMode !== void 0) out.defaultMode = input.defaultMode;
|
|
193
|
+
if (input.maxPage !== void 0) out.maxPage = input.maxPage;
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Resolve one repository's config against the deployment policy.
|
|
198
|
+
*
|
|
199
|
+
* Returns an object whose policy keys are GETTERS, so a repository constructed
|
|
200
|
+
* before `configurePaginationDefaults` still answers with the policy — and a
|
|
201
|
+
* test that resets the policy does not have to rebuild its repositories. Keys
|
|
202
|
+
* the repository set explicitly are plain values and never consult the policy.
|
|
203
|
+
*/
|
|
204
|
+
function bindPaginationDefaults(resolved, config) {
|
|
205
|
+
define(resolved, "defaultCountStrategy", config.defaultCountStrategy, "exact");
|
|
206
|
+
define(resolved, "defaultCountLimit", config.defaultCountLimit, 1e4);
|
|
207
|
+
define(resolved, "cursorSecret", config.cursorSecret, void 0);
|
|
208
|
+
define(resolved, "defaultMode", config.defaultMode, void 0);
|
|
209
|
+
define(resolved, "maxPage", config.maxPage, 1e4);
|
|
210
|
+
return resolved;
|
|
211
|
+
}
|
|
212
|
+
function define(target, key, explicit, fallback) {
|
|
213
|
+
Object.defineProperty(target, key, {
|
|
214
|
+
enumerable: true,
|
|
215
|
+
configurable: true,
|
|
216
|
+
get: () => explicit !== void 0 ? explicit : policy()[key] ?? fallback
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
//#endregion
|
|
220
|
+
//#region src/pagination/utils/filter.ts
|
|
221
|
+
/**
|
|
222
|
+
* Combine the caller's filters with the keyset position predicate.
|
|
223
|
+
*
|
|
224
|
+
* NEVER by spreading. `{ ...baseFilters, $or: position }` REPLACES a caller's own
|
|
225
|
+
* `$or`, and `{ ...baseFilters, _id: { $lt } }` replaces a caller's `_id: { $in }` —
|
|
226
|
+
* so page 1 honoured the filter and page 2 silently widened past it. Nothing threw;
|
|
227
|
+
* the second page simply had rows the first page's filter excluded.
|
|
228
|
+
*
|
|
229
|
+
* `$and` composes without collision whatever keys either side uses. The spread is
|
|
230
|
+
* kept only when no key is shared, because a flat filter is what every existing
|
|
231
|
+
* test, index hint and explain plan was written against.
|
|
232
|
+
*/
|
|
233
|
+
function combine(baseFilters, position) {
|
|
234
|
+
if (!Object.keys(position).some((key) => key in baseFilters)) return {
|
|
235
|
+
...baseFilters,
|
|
236
|
+
...position
|
|
237
|
+
};
|
|
238
|
+
return { $and: [baseFilters, position] };
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* The "strictly after this value" predicates for ONE field of the cascade,
|
|
242
|
+
* with the null boundary handled on BOTH sides rather than emitted.
|
|
243
|
+
*
|
|
244
|
+
* BSON orders `null` (and a missing field, which sorts with it) below every
|
|
245
|
+
* typed value, and `$gt`/`$lt` never cross that line: `{ $lt: 0 }` does not
|
|
246
|
+
* match null, `{ $gt: null }` matches nothing. So a walk that reached the
|
|
247
|
+
* boundary simply ended — `hasMore: false`, every null-valued row unread, and
|
|
248
|
+
* no error. Four positions, four answers:
|
|
249
|
+
*
|
|
250
|
+
* asc, typed v → `{ $gt: v }` nulls sorted BEFORE v; already passed
|
|
251
|
+
* asc, null → `{ $ne: null }` every typed value comes after null
|
|
252
|
+
* desc, typed v → `{ $lt: v }` OR `null` nulls sort AFTER every typed value
|
|
253
|
+
* desc, null → nothing nulls are last; nothing follows
|
|
254
|
+
*
|
|
255
|
+
* A position can therefore need two branches (desc, typed) or none (desc,
|
|
256
|
+
* null), which is why this returns a list and the cascade emits one `$or`
|
|
257
|
+
* branch per entry. `{ field: null }` matches null AND missing — exactly the
|
|
258
|
+
* set that sorts at that position.
|
|
259
|
+
*/
|
|
260
|
+
function afterValue(operator, value) {
|
|
261
|
+
const isNull = value === null || value === void 0;
|
|
262
|
+
if (operator === "$gt") return isNull ? [{ $ne: null }] : [{ $gt: value }];
|
|
263
|
+
return isNull ? [] : [{ $lt: value }, null];
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Builds MongoDB filter for keyset pagination
|
|
267
|
+
* Creates compound $or condition for proper cursor-based filtering
|
|
268
|
+
*
|
|
269
|
+
* Every field takes the operator from ITS OWN direction, so a mixed-direction
|
|
270
|
+
* sort (`{ priority: 1, createdAt: -1 }`, the ESR-shaped index) paginates
|
|
271
|
+
* correctly: "after (p, t)" is `p > p0 OR (p = p0 AND t < t0) OR (p = p0 AND
|
|
272
|
+
* t = t0 AND _id < id0)`. The cascade is the tuple comparison; the directions
|
|
273
|
+
* are per position in the tuple.
|
|
274
|
+
*
|
|
275
|
+
* @param baseFilters - Existing query filters
|
|
276
|
+
* @param sort - Normalized sort specification (validated; `_id` present)
|
|
277
|
+
* @param cursorValue - Primary field value from cursor
|
|
278
|
+
* @param cursorId - _id value from cursor
|
|
279
|
+
* @returns MongoDB filter with keyset condition
|
|
280
|
+
*
|
|
281
|
+
* @example
|
|
282
|
+
* buildKeysetFilter(
|
|
283
|
+
* { status: 'active' },
|
|
284
|
+
* { createdAt: -1, _id: -1 },
|
|
285
|
+
* new Date('2024-01-01'),
|
|
286
|
+
* new ObjectId('...')
|
|
287
|
+
* )
|
|
288
|
+
* // Returns:
|
|
289
|
+
* // {
|
|
290
|
+
* // status: 'active',
|
|
291
|
+
* // $or: [
|
|
292
|
+
* // { createdAt: { $lt: Date('2024-01-01') } },
|
|
293
|
+
* // { createdAt: Date('2024-01-01'), _id: { $lt: ObjectId('...') } }
|
|
294
|
+
* // ]
|
|
295
|
+
* // }
|
|
296
|
+
*/
|
|
297
|
+
function buildKeysetFilter(baseFilters, sort, cursorValue, cursorId, cursorValues) {
|
|
298
|
+
const sortFields = Object.keys(sort).filter((k) => k !== "_id");
|
|
299
|
+
const idOperator = (sort._id ?? sort[sortFields[0]] ?? -1) === 1 ? "$gt" : "$lt";
|
|
300
|
+
if (sortFields.length <= 1 && !cursorValues) {
|
|
301
|
+
const primaryField = sortFields[0] || "_id";
|
|
302
|
+
if (primaryField === "_id") return combine(baseFilters, { _id: { [idOperator]: cursorId } });
|
|
303
|
+
const ranges = afterValue(sort[primaryField] === 1 ? "$gt" : "$lt", cursorValue).map((r) => ({ [primaryField]: r }));
|
|
304
|
+
const tie = {
|
|
305
|
+
[primaryField]: cursorValue ?? null,
|
|
306
|
+
_id: { [idOperator]: cursorId }
|
|
307
|
+
};
|
|
308
|
+
return combine(baseFilters, ranges.length ? { $or: [...ranges, tie] } : tie);
|
|
309
|
+
}
|
|
310
|
+
const values = cursorValues || { [sortFields[0]]: cursorValue };
|
|
311
|
+
const allFields = [...sortFields, "_id"];
|
|
312
|
+
const allValues = {
|
|
313
|
+
...values,
|
|
314
|
+
_id: cursorId
|
|
315
|
+
};
|
|
316
|
+
const orConditions = [];
|
|
317
|
+
for (let i = 0; i < allFields.length; i++) {
|
|
318
|
+
const field = allFields[i];
|
|
319
|
+
const operator = field === "_id" ? idOperator : sort[field] === 1 ? "$gt" : "$lt";
|
|
320
|
+
const prefix = {};
|
|
321
|
+
for (let j = 0; j < i; j++) prefix[allFields[j]] = allValues[allFields[j]] ?? null;
|
|
322
|
+
const ranges = field === "_id" ? [{ [idOperator]: cursorId }] : afterValue(operator, allValues[field]);
|
|
323
|
+
for (const range of ranges) orConditions.push({
|
|
324
|
+
...prefix,
|
|
325
|
+
[field]: range
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
if (orConditions.length === 0) return combine(baseFilters, { _id: { $in: [] } });
|
|
329
|
+
return combine(baseFilters, { $or: orConditions });
|
|
330
|
+
}
|
|
331
|
+
//#endregion
|
|
332
|
+
//#region src/pagination/utils/cursor.ts
|
|
333
|
+
/**
|
|
334
|
+
* Cursor Utilities
|
|
335
|
+
*
|
|
336
|
+
* Encoding and decoding of cursor tokens for keyset pagination.
|
|
337
|
+
* Cursors are base64-encoded JSON containing position data and metadata.
|
|
338
|
+
*/
|
|
339
|
+
var cursor_exports = /* @__PURE__ */ __exportAll({
|
|
340
|
+
decodeCursor: () => decodeCursor,
|
|
341
|
+
encodeCursor: () => encodeCursor,
|
|
342
|
+
readSortValue: () => readSortValue,
|
|
343
|
+
resolveCursorFilter: () => resolveCursorFilter,
|
|
344
|
+
validateCursorSort: () => validateCursorSort,
|
|
345
|
+
validateCursorVersion: () => validateCursorVersion
|
|
346
|
+
});
|
|
347
|
+
/**
|
|
348
|
+
* Encodes document values and sort metadata into a base64 cursor token
|
|
349
|
+
*
|
|
350
|
+
* @param doc - Document to extract cursor values from
|
|
351
|
+
* @param primaryField - Primary sort field name
|
|
352
|
+
* @param sort - Normalized sort specification
|
|
353
|
+
* @param version - Cursor version for forward compatibility
|
|
354
|
+
* @returns Base64-encoded cursor token
|
|
355
|
+
*/
|
|
356
|
+
function encodeCursor(doc, primaryField, sort, version = 1, secret) {
|
|
357
|
+
const primaryValue = readSortValue(doc, primaryField);
|
|
358
|
+
const idValue = doc._id;
|
|
359
|
+
const sortFields = Object.keys(sort).filter((k) => k !== "_id");
|
|
360
|
+
const vals = {};
|
|
361
|
+
const types = {};
|
|
362
|
+
for (const field of sortFields) {
|
|
363
|
+
const value = readSortValue(doc, field);
|
|
364
|
+
vals[field] = serializeValue(value);
|
|
365
|
+
types[field] = getValueType(value);
|
|
366
|
+
}
|
|
367
|
+
const payload = {
|
|
368
|
+
v: serializeValue(primaryValue),
|
|
369
|
+
t: getValueType(primaryValue),
|
|
370
|
+
id: serializeValue(idValue),
|
|
371
|
+
idType: getValueType(idValue),
|
|
372
|
+
sort,
|
|
373
|
+
ver: version,
|
|
374
|
+
...sortFields.length > 1 && {
|
|
375
|
+
vals,
|
|
376
|
+
types
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
/**
|
|
380
|
+
* `base64url`, not `base64`: a cursor rides in `?after=`, and standard base64
|
|
381
|
+
* puts `+` in it — which a query-string parser reads as a SPACE, so the token
|
|
382
|
+
* fails to decode unless every client remembers to percent-encode it. The
|
|
383
|
+
* URL-safe alphabet (`-` `_`, no padding) survives the round trip as-is, and
|
|
384
|
+
* matches what `@classytic/repo-core`'s codec emits.
|
|
385
|
+
*
|
|
386
|
+
* Decoding accepts BOTH alphabets (Node's decoder does), so a token issued
|
|
387
|
+
* before this change keeps working.
|
|
388
|
+
*/
|
|
389
|
+
return attachSignature(Buffer.from(JSON.stringify(payload)).toString("base64url"), resolveCursorSecrets(secret));
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Decodes a cursor token back into document values and sort metadata
|
|
393
|
+
*
|
|
394
|
+
* @param token - Base64-encoded cursor token
|
|
395
|
+
* @returns Decoded cursor data
|
|
396
|
+
* @throws Error if token is invalid or malformed
|
|
397
|
+
*/
|
|
398
|
+
function decodeCursor(token, secret) {
|
|
399
|
+
const verified = verifySignature(token, resolveCursorSecrets(secret));
|
|
400
|
+
let json;
|
|
401
|
+
try {
|
|
402
|
+
json = Buffer.from(verified, "base64").toString("utf-8");
|
|
403
|
+
} catch {
|
|
404
|
+
throw new Error("Invalid cursor token: not valid base64");
|
|
405
|
+
}
|
|
406
|
+
let payload;
|
|
407
|
+
try {
|
|
408
|
+
payload = JSON.parse(json);
|
|
409
|
+
} catch {
|
|
410
|
+
throw new Error("Invalid cursor token: not valid JSON");
|
|
411
|
+
}
|
|
412
|
+
if (!payload || typeof payload !== "object" || !("v" in payload) || !("t" in payload) || !("id" in payload) || !("idType" in payload) || !payload.sort || typeof payload.sort !== "object" || typeof payload.ver !== "number") throw new Error("Invalid cursor token: malformed payload structure");
|
|
413
|
+
const VALID_TYPES = [
|
|
414
|
+
"date",
|
|
415
|
+
"objectid",
|
|
416
|
+
"boolean",
|
|
417
|
+
"number",
|
|
418
|
+
"string",
|
|
419
|
+
"null",
|
|
420
|
+
"unknown"
|
|
421
|
+
];
|
|
422
|
+
if (!VALID_TYPES.includes(payload.t) || !VALID_TYPES.includes(payload.idType)) throw new Error("Invalid cursor token: unrecognized value type");
|
|
423
|
+
try {
|
|
424
|
+
let values;
|
|
425
|
+
if (payload.vals && payload.types) {
|
|
426
|
+
values = {};
|
|
427
|
+
for (const [field, serialized] of Object.entries(payload.vals)) values[field] = rehydrateValue(serialized, payload.types[field]);
|
|
428
|
+
}
|
|
429
|
+
return {
|
|
430
|
+
value: rehydrateValue(payload.v, payload.t),
|
|
431
|
+
id: rehydrateValue(payload.id, payload.idType),
|
|
432
|
+
sort: payload.sort,
|
|
433
|
+
version: payload.ver,
|
|
434
|
+
...values && { values }
|
|
435
|
+
};
|
|
436
|
+
} catch {
|
|
437
|
+
throw new Error("Invalid cursor token: failed to rehydrate values");
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* Validates that cursor sort matches current query sort
|
|
442
|
+
*
|
|
443
|
+
* @param cursorSort - Sort specification from cursor
|
|
444
|
+
* @param currentSort - Sort specification from query
|
|
445
|
+
* @throws Error if sorts don't match
|
|
446
|
+
*/
|
|
447
|
+
function validateCursorSort(cursorSort, currentSort) {
|
|
448
|
+
if (JSON.stringify(cursorSort) !== JSON.stringify(currentSort)) throw new Error("Cursor sort does not match current query sort");
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Validates cursor version against the server's expected range.
|
|
452
|
+
*
|
|
453
|
+
* - Cursors newer than `expectedVersion` are rejected (client ahead of server).
|
|
454
|
+
* - Cursors older than `minVersion` are rejected (client cached a cursor from
|
|
455
|
+
* a pre-breaking-change deploy). Default `minVersion = 1` keeps the legacy
|
|
456
|
+
* "accept anything <= expected" behavior; bump it when you ship a breaking
|
|
457
|
+
* cursor format change so old clients restart pagination cleanly instead of
|
|
458
|
+
* silently paginating from the wrong position.
|
|
459
|
+
*/
|
|
460
|
+
function validateCursorVersion(cursorVersion, expectedVersion, minVersion = 1) {
|
|
461
|
+
if (cursorVersion > expectedVersion) throw new Error(`Cursor version ${cursorVersion} is newer than expected version ${expectedVersion}. Please upgrade.`);
|
|
462
|
+
if (cursorVersion < minVersion) throw new Error(`Cursor version ${cursorVersion} is older than minimum supported ${minVersion}. Pagination must restart.`);
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* Read a sort field off the last document of a page, following a dotted path.
|
|
466
|
+
*
|
|
467
|
+
* A sort key is a Mongo PATH (`metadata.progressPct`), and the query side has always
|
|
468
|
+
* treated it as one: `buildKeysetFilter` emits `{ 'metadata.progressPct': { $lt } }`,
|
|
469
|
+
* which Mongo resolves into the subdocument. The encode side read `doc[field]`, a
|
|
470
|
+
* property named with a literal dot, which no document has. So a keyset sort on any
|
|
471
|
+
* nested field encoded `v: null`, and page two came back through the null branch of
|
|
472
|
+
* the filter: only rows whose sort field IS null, silently, with no error. Exported
|
|
473
|
+
* for the unit test; not part of the public surface.
|
|
474
|
+
*/
|
|
475
|
+
function readSortValue(doc, field) {
|
|
476
|
+
if (!field.includes(".")) return doc[field];
|
|
477
|
+
let current = doc;
|
|
478
|
+
for (const segment of field.split(".")) {
|
|
479
|
+
if (current === null || typeof current !== "object") return void 0;
|
|
480
|
+
current = current[segment];
|
|
481
|
+
}
|
|
482
|
+
return current;
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Serializes a value for cursor storage
|
|
486
|
+
*/
|
|
487
|
+
function serializeValue(value) {
|
|
488
|
+
if (value === null || value === void 0) return null;
|
|
489
|
+
if (value instanceof Date) return value.toISOString();
|
|
490
|
+
if (value instanceof mongoose.Types.ObjectId) return value.toString();
|
|
491
|
+
return value;
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* Gets the type identifier for a value
|
|
495
|
+
*/
|
|
496
|
+
function getValueType(value) {
|
|
497
|
+
if (value === null || value === void 0) return "null";
|
|
498
|
+
if (value instanceof Date) return "date";
|
|
499
|
+
if (value instanceof mongoose.Types.ObjectId) return "objectid";
|
|
500
|
+
if (typeof value === "boolean") return "boolean";
|
|
501
|
+
if (typeof value === "number") return "number";
|
|
502
|
+
if (typeof value === "string") return "string";
|
|
503
|
+
return "unknown";
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Rehydrates a serialized value back to its original type
|
|
507
|
+
*/
|
|
508
|
+
function rehydrateValue(serialized, type) {
|
|
509
|
+
if (type === "null" || serialized === null) return null;
|
|
510
|
+
switch (type) {
|
|
511
|
+
case "date": return new Date(serialized);
|
|
512
|
+
case "objectid": return new mongoose.Types.ObjectId(serialized);
|
|
513
|
+
case "boolean": return serialized === true || serialized === "true";
|
|
514
|
+
case "number": return Number(serialized);
|
|
515
|
+
default: return serialized;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Resolves cursor token into MongoDB query filters.
|
|
520
|
+
* Shared by PaginationEngine.stream() and Repository.lookupPopulate() keyset path.
|
|
521
|
+
*
|
|
522
|
+
* Handles:
|
|
523
|
+
* - Plain 24-char hex ObjectId strings (fallback cursor)
|
|
524
|
+
* - Base64-encoded cursor tokens (standard cursor)
|
|
525
|
+
* - Cursor version and sort validation
|
|
526
|
+
*/
|
|
527
|
+
function resolveCursorFilter(after, sort, cursorVersion, baseFilters = {}, minCursorVersion = 1, buildSort = sort, secret) {
|
|
528
|
+
const secrets = resolveCursorSecrets(secret);
|
|
529
|
+
if (/^[a-f0-9]{24}$/i.test(after)) {
|
|
530
|
+
/**
|
|
531
|
+
* The bare-ObjectId fallback is an UNSIGNED position by construction, so a
|
|
532
|
+
* deployment that signs must refuse it — otherwise the signature
|
|
533
|
+
* requirement ships with a documented bypass: send a raw id instead of a
|
|
534
|
+
* token and skip verification entirely.
|
|
535
|
+
*/
|
|
536
|
+
if (signingRequired(secrets)) throw new Error("Invalid cursor token: this deployment signs cursors, so a bare ObjectId is not accepted as one");
|
|
537
|
+
const objectId = new mongoose.Types.ObjectId(after);
|
|
538
|
+
const idOperator = (buildSort._id || -1) === 1 ? "$gt" : "$lt";
|
|
539
|
+
return {
|
|
540
|
+
...baseFilters,
|
|
541
|
+
_id: { [idOperator]: objectId }
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
const cursor = decodeCursor(after, secret);
|
|
545
|
+
validateCursorVersion(cursor.version, cursorVersion, minCursorVersion);
|
|
546
|
+
validateCursorSort(cursor.sort, sort);
|
|
547
|
+
return buildKeysetFilter(baseFilters, buildSort, cursor.value, cursor.id, cursor.values);
|
|
548
|
+
}
|
|
549
|
+
//#endregion
|
|
550
|
+
//#region src/pagination/utils/index-hint.ts
|
|
551
|
+
/**
|
|
552
|
+
* The only operators that give the query planner a *bounded equality* it can
|
|
553
|
+
* lead an index with. Everything else (`$ne`, `$nin`, `$gt(e)`, `$lt(e)`,
|
|
554
|
+
* `$all`, `$elemMatch`, `$regex`, `$exists`, …) is a range predicate and, per ESR,
|
|
555
|
+
* must come AFTER the sort keys — never in the leading prefix.
|
|
556
|
+
*
|
|
557
|
+
* `$all` is intentionally excluded: although it combines equality matches,
|
|
558
|
+
* it targets multikey data, whose bounded index prefix cannot generally
|
|
559
|
+
* provide a subsequent index sort. Treating it as residual avoids claiming
|
|
560
|
+
* that a multikey-prefix compound index satisfies the sort.
|
|
561
|
+
*/
|
|
562
|
+
const EQUALITY_OPERATORS = /* @__PURE__ */ new Set(["$eq", "$in"]);
|
|
563
|
+
/**
|
|
564
|
+
* Is a top-level filter value a RANGE predicate (as opposed to equality)?
|
|
565
|
+
*
|
|
566
|
+
* Only a plain operator object — one whose own keys start with `$` — can be a
|
|
567
|
+
* range. Scalars, `Date`, `ObjectId`, arrays, and embedded-doc equality
|
|
568
|
+
* matches (`{ nested: { a: 1 } }`) are all equality for index-leading
|
|
569
|
+
* purposes. An operator object is equality iff every `$`-operator it uses is
|
|
570
|
+
* in {@link EQUALITY_OPERATORS}.
|
|
571
|
+
*/
|
|
572
|
+
function isRangePredicate(value) {
|
|
573
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
|
|
574
|
+
const operatorKeys = Object.keys(value).filter((k) => k.startsWith("$"));
|
|
575
|
+
if (operatorKeys.length === 0) return false;
|
|
576
|
+
return operatorKeys.some((k) => !EQUALITY_OPERATORS.has(k));
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* Split top-level filter fields into ESR equality vs range predicates.
|
|
580
|
+
*
|
|
581
|
+
* The keyset index check follows MongoDB's ESR rule (Equality → Sort →
|
|
582
|
+
* Range). Only equality predicates belong in an index's leading prefix; a
|
|
583
|
+
* range predicate placed there stops the planner from using any later key for
|
|
584
|
+
* bounds OR for satisfying the sort. So the compatibility check and the
|
|
585
|
+
* recommended-index shape must treat the two classes differently — a field
|
|
586
|
+
* filtered by a range should TRAIL the sort keys, not lead them.
|
|
587
|
+
*
|
|
588
|
+
* `$`-prefixed top-level keys (`$and`/`$or`/…) are ignored (the caller decides
|
|
589
|
+
* how to handle logical combinators).
|
|
590
|
+
*/
|
|
591
|
+
function classifyFilterFields(filters) {
|
|
592
|
+
const equality = [];
|
|
593
|
+
const range = [];
|
|
594
|
+
for (const [key, value] of Object.entries(filters)) {
|
|
595
|
+
if (key.startsWith("$")) continue;
|
|
596
|
+
if (isRangePredicate(value)) range.push(key);
|
|
597
|
+
else equality.push(key);
|
|
598
|
+
}
|
|
599
|
+
return {
|
|
600
|
+
equality,
|
|
601
|
+
range
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* The ESR index to recommend for a keyset query: equality → sort (incl. the `_id`
|
|
606
|
+
* tiebreaker) → range, each field ONCE. A filtered field that is also a sort key
|
|
607
|
+
* takes its sort position — Mongo rejects an index naming a key twice.
|
|
608
|
+
*/
|
|
609
|
+
function recommendKeysetIndex(filters, sort) {
|
|
610
|
+
const { equality, range } = classifyFilterFields(filters);
|
|
611
|
+
const notSorted = (f) => !Object.hasOwn(sort, f);
|
|
612
|
+
return [
|
|
613
|
+
...equality.filter(notSorted).map((f) => [f, 1]),
|
|
614
|
+
...Object.entries(sort),
|
|
615
|
+
...range.filter(notSorted).map((f) => [f, 1])
|
|
616
|
+
];
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* Read a Mongoose schema's declared indexes, defensively.
|
|
620
|
+
* Returns an empty array on any introspection failure.
|
|
621
|
+
*/
|
|
622
|
+
function readSchemaIndexes(Model) {
|
|
623
|
+
try {
|
|
624
|
+
const schema = Model.schema;
|
|
625
|
+
if (!schema || typeof schema.indexes !== "function") return [];
|
|
626
|
+
const raw = schema.indexes();
|
|
627
|
+
return Array.isArray(raw) ? raw : [];
|
|
628
|
+
} catch {
|
|
629
|
+
return [];
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
/**
|
|
633
|
+
* Does any schema-declared compound index satisfy ESR for the given
|
|
634
|
+
* equality filter fields + sort?
|
|
635
|
+
*
|
|
636
|
+
* Rules:
|
|
637
|
+
* 1. The first N keys of the index (N = filterFields.length) must be
|
|
638
|
+
* exactly the equality filter fields in any order (set equality).
|
|
639
|
+
* 2. The next M keys (M = sortKeys.length) must match the sort keys in
|
|
640
|
+
* exact declared order.
|
|
641
|
+
* 3. Directions must all match OR all be inverse (MongoDB can walk an
|
|
642
|
+
* index in reverse, but only if every sort field flips together).
|
|
643
|
+
* 4. Indexes with more total keys than needed are still accepted when
|
|
644
|
+
* the above prefix holds — Mongo just ignores trailing fields.
|
|
645
|
+
*
|
|
646
|
+
* Notes:
|
|
647
|
+
* - Hashed / text / 2dsphere indexes are skipped (not usable for sort).
|
|
648
|
+
* - Unique, partial, sparse options don't affect compatibility.
|
|
649
|
+
* - When `filterFields` is empty, a sort-only prefix match is sufficient.
|
|
650
|
+
*
|
|
651
|
+
* @param indexes Raw `[spec, options?]` tuples from `schema.indexes()`.
|
|
652
|
+
* @param filterFields Top-level equality filter keys (already stripped of `$*`).
|
|
653
|
+
* @param sort Normalized sort spec `{ field: 1 | -1 }`.
|
|
654
|
+
*/
|
|
655
|
+
function hasCompatibleKeysetIndex(indexes, filterFields, sort) {
|
|
656
|
+
const sortKeys = Object.keys(sort);
|
|
657
|
+
if (sortKeys.length === 0) return true;
|
|
658
|
+
const prefixFields = filterFields.filter((f) => !Object.hasOwn(sort, f));
|
|
659
|
+
const filterSet = new Set(prefixFields);
|
|
660
|
+
const prefixLen = prefixFields.length;
|
|
661
|
+
for (const [spec] of indexes) {
|
|
662
|
+
if (!spec || typeof spec !== "object") continue;
|
|
663
|
+
const entries = Object.entries(spec);
|
|
664
|
+
if (entries.length < prefixLen + sortKeys.length) continue;
|
|
665
|
+
if (entries.some(([, dir]) => typeof dir !== "number")) continue;
|
|
666
|
+
const equalityPrefix = entries.slice(0, prefixLen).map(([k]) => k);
|
|
667
|
+
if (equalityPrefix.length !== prefixLen) continue;
|
|
668
|
+
if (!(equalityPrefix.every((k) => filterSet.has(k)) && new Set(equalityPrefix).size === prefixLen)) continue;
|
|
669
|
+
const sortSuffix = entries.slice(prefixLen, prefixLen + sortKeys.length);
|
|
670
|
+
if (sortSuffix.length !== sortKeys.length) continue;
|
|
671
|
+
let namesMatch = true;
|
|
672
|
+
for (let i = 0; i < sortKeys.length; i++) if (sortSuffix[i][0] !== sortKeys[i]) {
|
|
673
|
+
namesMatch = false;
|
|
674
|
+
break;
|
|
675
|
+
}
|
|
676
|
+
if (!namesMatch) continue;
|
|
677
|
+
const forward = sortKeys.every((k, i) => sortSuffix[i][1] === sort[k]);
|
|
678
|
+
const reverse = sortKeys.every((k, i) => sortSuffix[i][1] === -sort[k]);
|
|
679
|
+
if (forward || reverse) return true;
|
|
680
|
+
}
|
|
681
|
+
return false;
|
|
682
|
+
}
|
|
683
|
+
//#endregion
|
|
684
|
+
//#region src/pagination/utils/sort.ts
|
|
685
|
+
var sort_exports = /* @__PURE__ */ __exportAll({
|
|
686
|
+
getPrimaryField: () => getPrimaryField,
|
|
687
|
+
invertSort: () => invertSort,
|
|
688
|
+
normalizeSort: () => normalizeSort,
|
|
689
|
+
validateKeysetSort: () => validateKeysetSort
|
|
690
|
+
});
|
|
691
|
+
/**
|
|
692
|
+
* Normalizes sort object to ensure stable key order
|
|
693
|
+
* Primary fields first, _id last (not alphabetical)
|
|
694
|
+
*
|
|
695
|
+
* @param sort - Sort specification
|
|
696
|
+
* @returns Normalized sort with stable key order
|
|
697
|
+
*/
|
|
698
|
+
function normalizeSort(sort) {
|
|
699
|
+
const normalized = {};
|
|
700
|
+
Object.keys(sort).forEach((key) => {
|
|
701
|
+
if (key !== "_id") normalized[key] = sort[key];
|
|
702
|
+
});
|
|
703
|
+
if (sort._id !== void 0) normalized._id = sort._id;
|
|
704
|
+
return normalized;
|
|
705
|
+
}
|
|
706
|
+
/**
|
|
707
|
+
* Validates and normalizes sort for keyset pagination.
|
|
708
|
+
* Auto-adds `_id` tie-breaker if needed; ensures `_id` direction matches the
|
|
709
|
+
* primary field.
|
|
710
|
+
*
|
|
711
|
+
* **Known limitation (MongoDB mixed-type sort):** keyset pagination across a
|
|
712
|
+
* field that can hold both typed values (Date, Number) and `null`/`undefined`
|
|
713
|
+
* is lossy — `$lt/$gt` don't cross the type boundary in BSON comparison, so
|
|
714
|
+
* some docs at the null/non-null boundary are unreachable. If your sort field
|
|
715
|
+
* is nullable, either:
|
|
716
|
+
* - sort by `_id` alone (always safe), or
|
|
717
|
+
* - use `allowedPrimaryFields` to lock keyset sort to fields your schema
|
|
718
|
+
* guarantees non-null (see `PaginationConfig.strictKeysetSortFields`).
|
|
719
|
+
*
|
|
720
|
+
* @param sort - Sort specification
|
|
721
|
+
* @param allowedPrimaryFields - Optional allowlist of primary (non-_id) sort
|
|
722
|
+
* field names. When set, rejects sorts whose primary field isn't listed.
|
|
723
|
+
* `_id` is always allowed.
|
|
724
|
+
* @returns Validated and normalized sort
|
|
725
|
+
* @throws Error if sort is invalid for keyset pagination
|
|
726
|
+
*/
|
|
727
|
+
function validateKeysetSort(sort, allowedPrimaryFields) {
|
|
728
|
+
const keys = Object.keys(sort);
|
|
729
|
+
if (keys.length === 0) throw new Error("Keyset pagination requires at least one sort field");
|
|
730
|
+
if (keys.length === 1 && keys[0] === "_id") return normalizeSort(sort);
|
|
731
|
+
for (const key of keys) if (sort[key] !== 1 && sort[key] !== -1) throw new Error(`Invalid sort direction for "${key}": must be 1 or -1, got ${sort[key]}`);
|
|
732
|
+
const nonIdKeys = keys.filter((k) => k !== "_id");
|
|
733
|
+
const primaryDirection = sort[nonIdKeys[0]];
|
|
734
|
+
if (allowedPrimaryFields && allowedPrimaryFields.length > 0) {
|
|
735
|
+
for (const key of nonIdKeys) if (!allowedPrimaryFields.includes(key)) throw new Error(`Keyset sort field "${key}" is not in the strictKeysetSortFields allowlist. Allowed: ${allowedPrimaryFields.join(", ")}. (See PaginationConfig.strictKeysetSortFields — protects against lossy null/non-null keyset boundaries.)`);
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* Mixed directions are allowed. `{ priority: 1, createdAt: -1 }` is the
|
|
739
|
+
* ESR-shaped compound index every planner guide recommends, and the keyset
|
|
740
|
+
* predicate is a tuple comparison whose operator is chosen PER POSITION
|
|
741
|
+
* (`buildKeysetFilter`), so nothing about it needs the directions to agree.
|
|
742
|
+
* Forbidding them pushed callers onto the offset path — `skip(n)` — which is
|
|
743
|
+
* the cost keyset exists to avoid.
|
|
744
|
+
*
|
|
745
|
+
* `_id` keeps whatever direction the caller gave it; when absent it follows
|
|
746
|
+
* the primary field, so a sort that never mentions `_id` behaves as before.
|
|
747
|
+
*/
|
|
748
|
+
if (!keys.includes("_id")) return normalizeSort({
|
|
749
|
+
...sort,
|
|
750
|
+
_id: primaryDirection
|
|
751
|
+
});
|
|
752
|
+
return normalizeSort(sort);
|
|
753
|
+
}
|
|
754
|
+
/**
|
|
755
|
+
* Inverts sort directions (1 becomes -1, -1 becomes 1)
|
|
756
|
+
*
|
|
757
|
+
* @param sort - Sort specification
|
|
758
|
+
* @returns Inverted sort
|
|
759
|
+
*/
|
|
760
|
+
function invertSort(sort) {
|
|
761
|
+
const inverted = {};
|
|
762
|
+
Object.keys(sort).forEach((key) => {
|
|
763
|
+
inverted[key] = sort[key] === 1 ? -1 : 1;
|
|
764
|
+
});
|
|
765
|
+
return inverted;
|
|
766
|
+
}
|
|
767
|
+
/**
|
|
768
|
+
* Extracts primary sort field (first non-_id field)
|
|
769
|
+
*
|
|
770
|
+
* @param sort - Sort specification
|
|
771
|
+
* @returns Primary field name
|
|
772
|
+
*/
|
|
773
|
+
function getPrimaryField(sort) {
|
|
774
|
+
return Object.keys(sort).find((k) => k !== "_id") || "_id";
|
|
775
|
+
}
|
|
776
|
+
//#endregion
|
|
777
|
+
//#region src/pagination/PaginationEngine.ts
|
|
778
|
+
/**
|
|
779
|
+
* The sort fields a reader cares about, for MESSAGES only — never for deciding
|
|
780
|
+
* whether an index is adequate.
|
|
781
|
+
*
|
|
782
|
+
* `_id` is appended to every keyset sort as the tiebreaker, and naming it in
|
|
783
|
+
* the "sort [...]" half of a warning is noise, since the caller never wrote it.
|
|
784
|
+
* Naming it in the RECOMMENDED INDEX is mandatory, which is why that half uses
|
|
785
|
+
* the full sort.
|
|
786
|
+
*
|
|
787
|
+
* This used to strip `_id` before the compatibility CHECK too, on the stated
|
|
788
|
+
* grounds that "an index covering the primary sort is still efficient — the
|
|
789
|
+
* planner uses the index for ordering and only pays an in-memory tiebreak on
|
|
790
|
+
* duplicate primary values". That is false, and measurably so: against 20k rows
|
|
791
|
+
* with heavy ties on the sort field, an index without `_id` produced a BLOCKING
|
|
792
|
+
* `SORT` stage examining all 20,000 documents to return 20, where the same
|
|
793
|
+
* index with `_id` examined exactly 20. Not a tiebreak — a full sort of the
|
|
794
|
+
* matching range, on every page. `tests/integration/keyset-index-explain.test.ts`
|
|
795
|
+
* pins it with a real `explain('executionStats')`.
|
|
796
|
+
*/
|
|
797
|
+
function sortFieldsForMessage(sort) {
|
|
798
|
+
return Object.keys(sort).filter((k) => k !== "_id");
|
|
799
|
+
}
|
|
800
|
+
function ensureKeysetSelectIncludesCursorFields(select, sort) {
|
|
801
|
+
if (!select) return select;
|
|
802
|
+
const requiredFields = /* @__PURE__ */ new Set([...Object.keys(sort), "_id"]);
|
|
803
|
+
if (typeof select === "string") {
|
|
804
|
+
const fields = select.split(/[,\s]+/).map((field) => field.trim()).filter(Boolean);
|
|
805
|
+
if (fields.length > 0 && fields.every((field) => field.startsWith("-"))) return select;
|
|
806
|
+
const merged = new Set(fields);
|
|
807
|
+
for (const field of requiredFields) merged.add(field);
|
|
808
|
+
return Array.from(merged).join(" ");
|
|
809
|
+
}
|
|
810
|
+
if (Array.isArray(select)) {
|
|
811
|
+
const fields = select.map((field) => field.trim()).filter(Boolean);
|
|
812
|
+
if (fields.length > 0 && fields.every((field) => field.startsWith("-"))) return select;
|
|
813
|
+
const merged = new Set(fields);
|
|
814
|
+
for (const field of requiredFields) merged.add(field);
|
|
815
|
+
return Array.from(merged);
|
|
816
|
+
}
|
|
817
|
+
const projection = { ...select };
|
|
818
|
+
if (!Object.values(projection).some((value) => value === 1)) return select;
|
|
819
|
+
for (const field of requiredFields) projection[field] = 1;
|
|
820
|
+
return projection;
|
|
821
|
+
}
|
|
822
|
+
/** The library's ceiling when a deployment names none. GitHub shows `1000+`; Elasticsearch stops at 10000. */
|
|
823
|
+
const DEFAULT_COUNT_LIMIT = 1e4;
|
|
824
|
+
/**
|
|
825
|
+
* A count ceiling must be a positive whole number.
|
|
826
|
+
*
|
|
827
|
+
* `0`, a negative, a fraction and `NaN` all reach `.limit()` as "no limit" in
|
|
828
|
+
* one driver path or another — so an operator who set `COUNT_LIMIT=0` meaning
|
|
829
|
+
* "never count" would instead get the unbounded scan the strategy exists to
|
|
830
|
+
* prevent, and nothing would say so. Fall back to the library's own ceiling
|
|
831
|
+
* rather than honouring a value that cannot mean what it says.
|
|
832
|
+
*/
|
|
833
|
+
function resolveCountLimit(value) {
|
|
834
|
+
return Number.isInteger(value) && value > 0 ? value : DEFAULT_COUNT_LIMIT;
|
|
835
|
+
}
|
|
836
|
+
/**
|
|
837
|
+
* Production-grade pagination engine for MongoDB
|
|
838
|
+
* Supports offset, keyset (cursor), and aggregate pagination
|
|
839
|
+
*/
|
|
840
|
+
var PaginationEngine = class {
|
|
841
|
+
Model;
|
|
842
|
+
config;
|
|
843
|
+
/**
|
|
844
|
+
* Lazily-cached schema index snapshot used by stream() to decide whether
|
|
845
|
+
* to emit the "missing compound index" warning. Computed on first use.
|
|
846
|
+
*/
|
|
847
|
+
_cachedSchemaIndexes = null;
|
|
848
|
+
/**
|
|
849
|
+
* Create a new pagination engine
|
|
850
|
+
*
|
|
851
|
+
* @param Model - Mongoose model to paginate
|
|
852
|
+
* @param config - Pagination configuration
|
|
853
|
+
*/
|
|
854
|
+
constructor(Model, config = {}) {
|
|
855
|
+
this.Model = Model;
|
|
856
|
+
/**
|
|
857
|
+
* `defaultCountStrategy`, `defaultCountLimit`, `defaultMode` and `maxPage`
|
|
858
|
+
* are NOT resolved
|
|
859
|
+
* here — `bindPaginationDefaults` installs them as getters that fall back
|
|
860
|
+
* to the deployment policy (`configurePaginationDefaults`). A kernel-built
|
|
861
|
+
* repository passes no config at all, so without that seam a host has no
|
|
862
|
+
* way to say "this deployment does not count rows" and inherits an
|
|
863
|
+
* O(matching rows) `countDocuments` on every page. Reading them through
|
|
864
|
+
* `this.config.*` is unchanged for every call site.
|
|
865
|
+
*/
|
|
866
|
+
this.config = bindPaginationDefaults({
|
|
867
|
+
defaultLimit: config.defaultLimit ?? 10,
|
|
868
|
+
maxLimit: config.maxLimit ?? 100,
|
|
869
|
+
deepPageThreshold: config.deepPageThreshold ?? 100,
|
|
870
|
+
cursorVersion: config.cursorVersion ?? 1,
|
|
871
|
+
minCursorVersion: config.minCursorVersion ?? 1,
|
|
872
|
+
strictKeysetSortFields: config.strictKeysetSortFields,
|
|
873
|
+
useEstimatedCount: config.useEstimatedCount ?? false
|
|
874
|
+
}, config);
|
|
875
|
+
}
|
|
876
|
+
/** Memoized schema index lookup — avoids re-walking schema on every stream(). */
|
|
877
|
+
_getSchemaIndexes() {
|
|
878
|
+
if (this._cachedSchemaIndexes !== null) return this._cachedSchemaIndexes;
|
|
879
|
+
this._cachedSchemaIndexes = readSchemaIndexes(this.Model);
|
|
880
|
+
return this._cachedSchemaIndexes;
|
|
881
|
+
}
|
|
882
|
+
/**
|
|
883
|
+
* Offset-based pagination using skip/limit
|
|
884
|
+
* Best for small datasets and when users need random page access
|
|
885
|
+
* O(n) performance - slower for deep pages
|
|
886
|
+
*
|
|
887
|
+
* @param options - Pagination options
|
|
888
|
+
* @returns Pagination result with total count
|
|
889
|
+
*
|
|
890
|
+
* @example
|
|
891
|
+
* const result = await engine.paginate({
|
|
892
|
+
* filters: { status: 'active' },
|
|
893
|
+
* sort: { createdAt: -1 },
|
|
894
|
+
* page: 1,
|
|
895
|
+
* limit: 20
|
|
896
|
+
* });
|
|
897
|
+
* console.log(result.data, result.total, result.hasNext);
|
|
898
|
+
*/
|
|
899
|
+
async paginate(options = {}) {
|
|
900
|
+
const { filters = {}, sort, countFilters, page = 1, limit = this.config.defaultLimit, select, populate = [], lean = true, session, hint, maxTimeMS, countStrategy = this.config.defaultCountStrategy, countLimit = this.config.defaultCountLimit, readPreference, collation } = options;
|
|
901
|
+
const sanitizedPage = validatePage(page, this.config);
|
|
902
|
+
const sanitizedLimit = validateLimit(limit, this.config);
|
|
903
|
+
const skip = calculateSkip(sanitizedPage, sanitizedLimit);
|
|
904
|
+
/**
|
|
905
|
+
* Fetch limit+1 whenever the count cannot answer `hasNext`.
|
|
906
|
+
*
|
|
907
|
+
* `none` never counts. `capped` counts only to the ceiling, so once `total`
|
|
908
|
+
* sits AT the ceiling it no longer knows whether a further page exists —
|
|
909
|
+
* deriving `hasNext` from `page < pages` there reports "no more results" in
|
|
910
|
+
* the middle of a collection, which is a wrong answer that looks like a
|
|
911
|
+
* right one. One extra document is the whole cost of avoiding it.
|
|
912
|
+
*/
|
|
913
|
+
const countBounded = countStrategy === "none" || countStrategy === "capped";
|
|
914
|
+
const fetchLimit = countBounded ? sanitizedLimit + 1 : sanitizedLimit;
|
|
915
|
+
let query = this.Model.find(filters);
|
|
916
|
+
if (select) query = query.select(select);
|
|
917
|
+
if (populate && (Array.isArray(populate) ? populate.length : populate)) query = query.populate(populate);
|
|
918
|
+
if (sort) query = query.sort(sort);
|
|
919
|
+
query = query.skip(skip).limit(fetchLimit).lean(lean);
|
|
920
|
+
if (collation) query = query.collation(collation);
|
|
921
|
+
if (session) query = query.session(session);
|
|
922
|
+
if (hint) query = query.hint(hint);
|
|
923
|
+
if (maxTimeMS) query = query.maxTimeMS(maxTimeMS);
|
|
924
|
+
if (readPreference) query = query.read(readPreference);
|
|
925
|
+
const hasFilters = Object.keys(filters).length > 0;
|
|
926
|
+
const useEstimated = this.config.useEstimatedCount && !hasFilters;
|
|
927
|
+
let runCount;
|
|
928
|
+
if ((countStrategy === "estimated" || useEstimated) && !hasFilters && !session) runCount = () => Promise.resolve(this.Model.estimatedDocumentCount());
|
|
929
|
+
else if (countStrategy === "none") runCount = () => Promise.resolve(0);
|
|
930
|
+
else if (countStrategy === "capped") {
|
|
931
|
+
/**
|
|
932
|
+
* `.limit(n)` on a count is MongoDB's own ceiling — the server stops
|
|
933
|
+
* scanning at n and returns n, so the work is O(n) rather than O(matching
|
|
934
|
+
* rows). Below the ceiling it returns the exact figure, so a small
|
|
935
|
+
* collection is unaffected by the strategy being on.
|
|
936
|
+
*/
|
|
937
|
+
const cappedTarget = countFilters ?? filters;
|
|
938
|
+
const cappedQuery = this.Model.countDocuments(cappedTarget).limit(resolveCountLimit(countLimit)).session(session ?? null);
|
|
939
|
+
if (collation) cappedQuery.collation(collation);
|
|
940
|
+
if (hint) cappedQuery.hint(hint);
|
|
941
|
+
if (maxTimeMS) cappedQuery.maxTimeMS(maxTimeMS);
|
|
942
|
+
if (readPreference) cappedQuery.read(readPreference);
|
|
943
|
+
runCount = () => cappedQuery.exec();
|
|
944
|
+
} else {
|
|
945
|
+
const countTarget = countFilters ?? filters;
|
|
946
|
+
const countQuery = this.Model.countDocuments(countTarget).session(session ?? null);
|
|
947
|
+
if (collation) countQuery.collation(collation);
|
|
948
|
+
if (hint) countQuery.hint(hint);
|
|
949
|
+
if (maxTimeMS) countQuery.maxTimeMS(maxTimeMS);
|
|
950
|
+
if (readPreference) countQuery.read(readPreference);
|
|
951
|
+
runCount = () => countQuery.exec();
|
|
952
|
+
}
|
|
953
|
+
const [data, total] = session ? [await query.exec(), await runCount()] : await Promise.all([query.exec(), runCount()]);
|
|
954
|
+
const totalPages = countStrategy === "none" ? 0 : calculateTotalPages(total, sanitizedLimit);
|
|
955
|
+
/**
|
|
956
|
+
* A capped count that came back AT its ceiling is a floor, not a total.
|
|
957
|
+
*
|
|
958
|
+
* Reported rather than inferred: `total === countLimit` is also what a
|
|
959
|
+
* collection of exactly that size legitimately returns, so a consumer
|
|
960
|
+
* cannot tell the two apart, and one that guesses renders `10,000+` over an
|
|
961
|
+
* exact 10,000 forever.
|
|
962
|
+
*/
|
|
963
|
+
const totalIsLowerBound = countStrategy === "capped" && total >= resolveCountLimit(countLimit);
|
|
964
|
+
let hasNext;
|
|
965
|
+
if (countBounded) {
|
|
966
|
+
hasNext = data.length > sanitizedLimit;
|
|
967
|
+
if (hasNext) data.pop();
|
|
968
|
+
} else hasNext = sanitizedPage < totalPages;
|
|
969
|
+
/**
|
|
970
|
+
* A CLAMPED limit is reported, not swallowed.
|
|
971
|
+
*
|
|
972
|
+
* `validateLimit` caps silently by design — an over-ask is benign and rejecting it
|
|
973
|
+
* would be worse (that contradiction cost a day: the querystring schema's `maximum`
|
|
974
|
+
* made the parser's documented clamp unreachable, so `?limit=200` 400'd and two UI
|
|
975
|
+
* callers rendered the failure as an empty list).
|
|
976
|
+
*
|
|
977
|
+
* But clamping quietly has its own cost. A caller asking for 1000 and receiving 100
|
|
978
|
+
* gets an arbitrary slice with no signal — which is exactly how a bank-account
|
|
979
|
+
* picker rendered "No accounts found" against 696 accounts, because the rows it
|
|
980
|
+
* wanted were outside the first page. `limit` in the response already carries the
|
|
981
|
+
* effective value; nobody compares it. A warning is the channel that is actually
|
|
982
|
+
* read.
|
|
983
|
+
*
|
|
984
|
+
* Deep-pagination takes precedence when both apply: it is the more actionable of
|
|
985
|
+
* the two, and stacking warnings turns a signal into noise.
|
|
986
|
+
*/
|
|
987
|
+
const requestedLimit = Number(limit);
|
|
988
|
+
const wasClamped = Number.isFinite(requestedLimit) && requestedLimit > sanitizedLimit;
|
|
989
|
+
const warning = shouldWarnDeepPagination(sanitizedPage, this.config.deepPageThreshold) ? `Deep pagination (page ${sanitizedPage}). Consider getAll({ after, sort, limit }) for better performance.` : wasClamped ? `Requested limit ${Math.floor(requestedLimit)} exceeds this repository's cap; returning ${sanitizedLimit}. Filter server-side or sort deterministically — the returned page is otherwise an arbitrary slice.` : void 0;
|
|
990
|
+
return {
|
|
991
|
+
method: "offset",
|
|
992
|
+
data,
|
|
993
|
+
page: sanitizedPage,
|
|
994
|
+
limit: sanitizedLimit,
|
|
995
|
+
total,
|
|
996
|
+
pages: totalPages,
|
|
997
|
+
hasNext,
|
|
998
|
+
hasPrev: sanitizedPage > 1,
|
|
999
|
+
...totalIsLowerBound && { totalIsLowerBound },
|
|
1000
|
+
...warning && { warning }
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
1003
|
+
/**
|
|
1004
|
+
* Keyset (cursor-based) pagination for high-performance streaming.
|
|
1005
|
+
* Best for large datasets, infinite scroll, real-time feeds.
|
|
1006
|
+
*
|
|
1007
|
+
* **Constant cost per page — but ONLY with an index that covers the whole
|
|
1008
|
+
* sort, tiebreaker included.** Keyset removes `skip(n)`; it does not by
|
|
1009
|
+
* itself remove a sort. `validateKeysetSort` appends `_id` to every sort, so
|
|
1010
|
+
* an index stopping at the primary field leaves Mongo to order the ties
|
|
1011
|
+
* itself: a blocking `SORT` over every row the filter matches, paid again on
|
|
1012
|
+
* every page. Measured at 20,000 documents examined to return 20, against 20
|
|
1013
|
+
* with the tiebreaker in the index.
|
|
1014
|
+
*
|
|
1015
|
+
* The dev-time warning below names the index to declare. Read it as a
|
|
1016
|
+
* correctness requirement for the performance claim, not as advice.
|
|
1017
|
+
*
|
|
1018
|
+
* @param options - Pagination options (sort is required)
|
|
1019
|
+
* @returns Pagination result with next cursor
|
|
1020
|
+
*
|
|
1021
|
+
* @example
|
|
1022
|
+
* // First page
|
|
1023
|
+
* const page1 = await engine.stream({
|
|
1024
|
+
* sort: { createdAt: -1 },
|
|
1025
|
+
* limit: 20
|
|
1026
|
+
* });
|
|
1027
|
+
*
|
|
1028
|
+
* // Next page using cursor
|
|
1029
|
+
* const page2 = await engine.stream({
|
|
1030
|
+
* sort: { createdAt: -1 },
|
|
1031
|
+
* after: page1.next,
|
|
1032
|
+
* limit: 20
|
|
1033
|
+
* });
|
|
1034
|
+
*/
|
|
1035
|
+
async stream(options) {
|
|
1036
|
+
const { filters = {}, sort, after, before, limit = this.config.defaultLimit, select, populate = [], lean = true, session, hint, maxTimeMS, readPreference, collation } = options;
|
|
1037
|
+
if (!sort) throw createError(400, "sort is required for keyset pagination");
|
|
1038
|
+
if (after && before) throw createError(400, "Pass `after` or `before`, not both");
|
|
1039
|
+
const sanitizedLimit = validateLimit(limit, this.config);
|
|
1040
|
+
const normalizedSort = validateKeysetSort(sort, this.config.strictKeysetSortFields);
|
|
1041
|
+
const filterKeys = Object.keys(filters).filter((k) => !k.startsWith("$"));
|
|
1042
|
+
const effectiveSortFields = sortFieldsForMessage(normalizedSort);
|
|
1043
|
+
if (process.env.NODE_ENV !== "test" && filterKeys.length > 0 && effectiveSortFields.length > 0) {
|
|
1044
|
+
const indexes = this._getSchemaIndexes();
|
|
1045
|
+
const { equality } = classifyFilterFields(filters);
|
|
1046
|
+
if (!(hasCompatibleKeysetIndex(indexes, filterKeys, normalizedSort) || equality.length !== filterKeys.length && hasCompatibleKeysetIndex(indexes, equality, normalizedSort))) {
|
|
1047
|
+
const indexFields = recommendKeysetIndex(filters, normalizedSort).map(([f, dir]) => `${f}: ${dir}`);
|
|
1048
|
+
warn(`[mongokit] Keyset pagination with filters [${filterKeys.join(", ")}] and sort [${effectiveSortFields.join(", ")}] has no matching schema-declared compound index. Without one, the tiebreaker forces a BLOCKING SORT over every matching row, on every page. Declare: { ${indexFields.join(", ")} }. (Collection-level indexes created outside the schema are not visible here.)`);
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
/**
|
|
1052
|
+
* Walking BACKWARDS is the forward walk with every direction inverted.
|
|
1053
|
+
*
|
|
1054
|
+
* `before: c` means "the page ending just before c", which is the same set
|
|
1055
|
+
* as "the first `limit` rows after c in the opposite order" — so the query
|
|
1056
|
+
* runs inverted and the rows are reversed back afterwards, leaving the
|
|
1057
|
+
* caller with data in the order they asked for.
|
|
1058
|
+
*
|
|
1059
|
+
* The CURSOR is still validated against the caller's own sort (that is what
|
|
1060
|
+
* it was minted under); only the QUERY is inverted.
|
|
1061
|
+
*/
|
|
1062
|
+
const backward = Boolean(before);
|
|
1063
|
+
const querySort = backward ? invertSort(normalizedSort) : normalizedSort;
|
|
1064
|
+
const cursor = backward ? before : after;
|
|
1065
|
+
let query = { ...filters };
|
|
1066
|
+
if (cursor) query = resolveCursorFilter(cursor, normalizedSort, this.config.cursorVersion, query, this.config.minCursorVersion, querySort, this.config.cursorSecret);
|
|
1067
|
+
const effectiveSelect = ensureKeysetSelectIncludesCursorFields(select, normalizedSort);
|
|
1068
|
+
let mongoQuery = this.Model.find(query);
|
|
1069
|
+
if (effectiveSelect) mongoQuery = mongoQuery.select(effectiveSelect);
|
|
1070
|
+
if (populate && (Array.isArray(populate) ? populate.length : populate)) mongoQuery = mongoQuery.populate(populate);
|
|
1071
|
+
mongoQuery = mongoQuery.sort(querySort).limit(sanitizedLimit + 1).lean(lean);
|
|
1072
|
+
if (collation) mongoQuery = mongoQuery.collation(collation);
|
|
1073
|
+
if (session) mongoQuery = mongoQuery.session(session);
|
|
1074
|
+
if (hint) mongoQuery = mongoQuery.hint(hint);
|
|
1075
|
+
if (maxTimeMS) mongoQuery = mongoQuery.maxTimeMS(maxTimeMS);
|
|
1076
|
+
if (readPreference) mongoQuery = mongoQuery.read(readPreference);
|
|
1077
|
+
const data = await mongoQuery.exec();
|
|
1078
|
+
const moreThatWay = data.length > sanitizedLimit;
|
|
1079
|
+
if (moreThatWay) data.pop();
|
|
1080
|
+
if (backward) data.reverse();
|
|
1081
|
+
/**
|
|
1082
|
+
* Which end of the walk the extra row spoke for.
|
|
1083
|
+
*
|
|
1084
|
+
* Forward, it says a next page exists. Backward, it says a page exists
|
|
1085
|
+
* BEFORE this one — and a next page certainly exists, because we arrived
|
|
1086
|
+
* from it. The mirror holds for the other edge: paging forward from a
|
|
1087
|
+
* cursor proves a previous page exists.
|
|
1088
|
+
*/
|
|
1089
|
+
const hasMore = backward ? true : moreThatWay;
|
|
1090
|
+
const hasPrev = backward ? moreThatWay : Boolean(after);
|
|
1091
|
+
const primaryField = getPrimaryField(normalizedSort);
|
|
1092
|
+
const mint = (doc) => doc ? encodeCursor(doc, primaryField, normalizedSort, this.config.cursorVersion, this.config.cursorSecret) : null;
|
|
1093
|
+
return {
|
|
1094
|
+
method: "keyset",
|
|
1095
|
+
data,
|
|
1096
|
+
limit: sanitizedLimit,
|
|
1097
|
+
hasMore,
|
|
1098
|
+
next: hasMore ? mint(data[data.length - 1]) : null,
|
|
1099
|
+
prev: hasPrev ? mint(data[0]) : null,
|
|
1100
|
+
hasPrev,
|
|
1101
|
+
end: mint(data[data.length - 1])
|
|
1102
|
+
};
|
|
1103
|
+
}
|
|
1104
|
+
/**
|
|
1105
|
+
* Aggregate pipeline with pagination.
|
|
1106
|
+
*
|
|
1107
|
+
* Runs the page and the count as TWO pipelines, concurrently — not one
|
|
1108
|
+
* `$facet`. `$facet` bundles every returned document and the count into a
|
|
1109
|
+
* SINGLE output document, and no stage may exceed the 16MB BSON limit, so a
|
|
1110
|
+
* page of large documents fails outright with `BSONObjectTooLarge` rather
|
|
1111
|
+
* than paginating. `$facet` also cannot use an index for the stages inside
|
|
1112
|
+
* it, so splitting is frequently the faster plan as well.
|
|
1113
|
+
*
|
|
1114
|
+
* The cost is that the two halves are separate reads and a concurrent write
|
|
1115
|
+
* can land between them, so `total` may disagree with `data` by a document.
|
|
1116
|
+
* That is already true of the offset path (`paginate` runs find and count
|
|
1117
|
+
* through one `Promise.all`) — this makes aggregate consistent with it, and
|
|
1118
|
+
* a count that is one row stale is a far smaller defect than a page that
|
|
1119
|
+
* cannot be fetched at all. `countStrategy: 'none'` runs ONE pipeline.
|
|
1120
|
+
*
|
|
1121
|
+
* @param options - Aggregation options
|
|
1122
|
+
* @returns Pagination result with total count
|
|
1123
|
+
*
|
|
1124
|
+
* @example
|
|
1125
|
+
* const result = await engine.aggregatePaginate({
|
|
1126
|
+
* pipeline: [
|
|
1127
|
+
* { $match: { status: 'active' } },
|
|
1128
|
+
* { $group: { _id: '$category', count: { $sum: 1 } } },
|
|
1129
|
+
* { $sort: { count: -1 } }
|
|
1130
|
+
* ],
|
|
1131
|
+
* page: 1,
|
|
1132
|
+
* limit: 20
|
|
1133
|
+
* });
|
|
1134
|
+
*/
|
|
1135
|
+
async aggregatePaginate(options = {}) {
|
|
1136
|
+
const { pipeline = [], page = 1, limit = this.config.defaultLimit, session, hint, maxTimeMS, countStrategy = this.config.defaultCountStrategy, countLimit = this.config.defaultCountLimit, readPreference, allowDiskUse } = options;
|
|
1137
|
+
const sanitizedPage = validatePage(page, this.config);
|
|
1138
|
+
const sanitizedLimit = validateLimit(limit, this.config);
|
|
1139
|
+
const skip = calculateSkip(sanitizedPage, sanitizedLimit);
|
|
1140
|
+
const countBounded = countStrategy === "none" || countStrategy === "capped";
|
|
1141
|
+
const fetchLimit = countBounded ? sanitizedLimit + 1 : sanitizedLimit;
|
|
1142
|
+
/** Every execution option both pipelines must carry identically. */
|
|
1143
|
+
const run = (stages) => {
|
|
1144
|
+
const aggregation = this.Model.aggregate(stages);
|
|
1145
|
+
if (session) aggregation.session(session);
|
|
1146
|
+
if (hint) aggregation.hint(hint);
|
|
1147
|
+
if (maxTimeMS) aggregation.option({ maxTimeMS });
|
|
1148
|
+
if (readPreference) aggregation.read(readPreference);
|
|
1149
|
+
if (allowDiskUse) aggregation.allowDiskUse(true);
|
|
1150
|
+
return aggregation.exec();
|
|
1151
|
+
};
|
|
1152
|
+
/**
|
|
1153
|
+
* `$limit` BEFORE `$count` is the aggregate ceiling — the stage stops
|
|
1154
|
+
* pulling documents at the bound, so the count costs O(countLimit) instead
|
|
1155
|
+
* of walking the whole pipeline output.
|
|
1156
|
+
*
|
|
1157
|
+
* `estimated` has no aggregate equivalent (`estimatedDocumentCount` reads
|
|
1158
|
+
* collection metadata and cannot see a pipeline), so it stays exact here —
|
|
1159
|
+
* documented on `AggregatePaginationOptions.countStrategy`.
|
|
1160
|
+
*/
|
|
1161
|
+
const countStages = countStrategy === "capped" ? [
|
|
1162
|
+
...pipeline,
|
|
1163
|
+
{ $limit: resolveCountLimit(countLimit) },
|
|
1164
|
+
{ $count: "count" }
|
|
1165
|
+
] : [...pipeline, { $count: "count" }];
|
|
1166
|
+
const runData = () => run([
|
|
1167
|
+
...pipeline,
|
|
1168
|
+
{ $skip: skip },
|
|
1169
|
+
{ $limit: fetchLimit }
|
|
1170
|
+
]);
|
|
1171
|
+
const runCount = () => countStrategy === "none" ? Promise.resolve([]) : run(countStages);
|
|
1172
|
+
const [dataRows, countRows] = session ? [await runData(), await runCount()] : await Promise.all([runData(), runCount()]);
|
|
1173
|
+
const data = dataRows;
|
|
1174
|
+
const total = countRows[0]?.count || 0;
|
|
1175
|
+
const totalPages = countStrategy === "none" ? 0 : calculateTotalPages(total, sanitizedLimit);
|
|
1176
|
+
const totalIsLowerBound = countStrategy === "capped" && total >= resolveCountLimit(countLimit);
|
|
1177
|
+
let hasNext;
|
|
1178
|
+
if (countBounded) {
|
|
1179
|
+
hasNext = data.length > sanitizedLimit;
|
|
1180
|
+
if (hasNext) data.pop();
|
|
1181
|
+
} else hasNext = sanitizedPage < totalPages;
|
|
1182
|
+
const warning = shouldWarnDeepPagination(sanitizedPage, this.config.deepPageThreshold) ? `Deep pagination in aggregate (page ${sanitizedPage}). Uses $skip internally.` : void 0;
|
|
1183
|
+
return {
|
|
1184
|
+
method: "aggregate",
|
|
1185
|
+
data,
|
|
1186
|
+
page: sanitizedPage,
|
|
1187
|
+
limit: sanitizedLimit,
|
|
1188
|
+
total,
|
|
1189
|
+
pages: totalPages,
|
|
1190
|
+
hasNext,
|
|
1191
|
+
hasPrev: sanitizedPage > 1,
|
|
1192
|
+
...totalIsLowerBound && { totalIsLowerBound },
|
|
1193
|
+
...warning && { warning }
|
|
1194
|
+
};
|
|
1195
|
+
}
|
|
1196
|
+
};
|
|
1197
|
+
//#endregion
|
|
1198
|
+
export { getPaginationDefaults as a, resolveCursorSecrets as c, configurePaginationDefaults as i, verifySignature as l, sort_exports as n, resetPaginationDefaults as o, cursor_exports as r, attachSignature as s, PaginationEngine as t };
|