@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
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { i as isValidIdForType, t as getSchemaIdType } from "./id-resolution-BqDrk0S2.mjs";
|
|
2
2
|
import { a as narrowToIds, i as keysetFilter, o as selectKeysetChunk } from "./id-chunks-BmAwsqNk.mjs";
|
|
3
|
-
import { a as count, b as
|
|
3
|
+
import { _ as SHORTHAND_OPS, a as count, b as create, c as getByQuery, d as deleteById, f as deleteByQuery, g as LookupBuilder, h as distinct, l as getOrCreate, n as update, o as exists, r as updateByQuery, s as getById, t as findOneAndUpdate, v as compileFilterToMongo, x as createMany, y as castFilterToSchema } from "./update-DG0fCwRJ.mjs";
|
|
4
4
|
import { n as warn } from "./logger-K7dPmu2U.mjs";
|
|
5
5
|
import { n as isDuplicateKeyError, r as parseDuplicateKeyError, t as createError } from "./error-BuByZwKU.mjs";
|
|
6
|
-
import {
|
|
6
|
+
import { a as createTxBoundRepo, d as resolveMongoCapabilities, i as withTransaction } from "./transaction-B5L9glO0.mjs";
|
|
7
|
+
import { c as resolveCursorSecrets, l as verifySignature, s as attachSignature, t as PaginationEngine } from "./PaginationEngine-Dvl8jcRY.mjs";
|
|
7
8
|
import { calculateTotalPages } from "./pagination/utils/limits.mjs";
|
|
8
9
|
import { hasNearOperator, rewriteNearForCount } from "./query/primitives/geo.mjs";
|
|
9
|
-
import { RepositoryBase, runChunkedArchive, runChunkedPurge, throwIfAborted, validatePluginOrder, withRetry } from "@classytic/repo-core/repository";
|
|
10
|
+
import { RepositoryBase, createDistributionGuard, runChunkedArchive, runChunkedPurge, throwIfAborted, validatePluginOrder, withRetry } from "@classytic/repo-core/repository";
|
|
10
11
|
import mongoose from "mongoose";
|
|
11
12
|
import { isFilter } from "@classytic/repo-core/filter";
|
|
12
13
|
import { VersionConflictError, conservativeMongoIsTransientConflict } from "@classytic/repo-core/errors";
|
|
@@ -118,257 +119,16 @@ async function purgeAnonymizeFunctional(repo, selectFilter, fields, limit, sessi
|
|
|
118
119
|
ordered: false,
|
|
119
120
|
session: session ?? void 0
|
|
120
121
|
});
|
|
122
|
+
await repo.emitAsync("after:anonymize", {
|
|
123
|
+
context: { ...session ? { session } : {} },
|
|
124
|
+
result: { ids: docs.map((doc) => doc._id) }
|
|
125
|
+
});
|
|
121
126
|
return {
|
|
122
127
|
processed: docs.length,
|
|
123
128
|
lastId: docs[docs.length - 1]?._id ?? null
|
|
124
129
|
};
|
|
125
130
|
}
|
|
126
131
|
//#endregion
|
|
127
|
-
//#region src/capabilities.ts
|
|
128
|
-
/**
|
|
129
|
-
* Read the provenance of a capability descriptor's `transactions` flag.
|
|
130
|
-
* A descriptor without the field is a static declaration (`'declared'`) —
|
|
131
|
-
* which is exactly right for sqlitekit / prismakit / any kit whose answer
|
|
132
|
-
* genuinely does not depend on a live deployment.
|
|
133
|
-
*/
|
|
134
|
-
function transactionResolutionOf(caps) {
|
|
135
|
-
const value = caps.transactionsResolution;
|
|
136
|
-
return value === "observed" || value === "unknown" ? value : "declared";
|
|
137
|
-
}
|
|
138
|
-
/**
|
|
139
|
-
* What MongoDB supports through mongokit's surface, DECLARED (not observed).
|
|
140
|
-
*
|
|
141
|
-
* Deployment-dependent flags (`transactions`, `changeStreams` — both need a
|
|
142
|
-
* replica set or mongos) are `true` here because they describe the product,
|
|
143
|
-
* not the server you happen to be pointed at. `nestedTransactions` is NOT one
|
|
144
|
-
* of them: it is `false` unconditionally, because the refusal comes from
|
|
145
|
-
* mongokit's own tx-bound proxy rather than from the deployment. **Do not read this constant to decide whether a live connection can run
|
|
146
|
-
* a transaction** — that is {@link resolveMongoCapabilities}, which is what
|
|
147
|
-
* `Repository#capabilities` returns. This one is for the cross-kit conformance
|
|
148
|
-
* harness (which overrides environment-specific limits where it is
|
|
149
|
-
* constructed) and as the base every observed descriptor is derived from.
|
|
150
|
-
*/
|
|
151
|
-
const MONGOKIT_CAPABILITIES = Object.freeze({
|
|
152
|
-
transactionsResolution: "declared",
|
|
153
|
-
transactions: true,
|
|
154
|
-
nestedTransactions: false,
|
|
155
|
-
transactionRetry: "managed",
|
|
156
|
-
upsert: true,
|
|
157
|
-
duplicateKeyError: true,
|
|
158
|
-
optimisticConcurrency: true,
|
|
159
|
-
distinct: true,
|
|
160
|
-
aggregate: true,
|
|
161
|
-
aggregateOps: Object.freeze({
|
|
162
|
-
percentile: true,
|
|
163
|
-
stddev: true,
|
|
164
|
-
topN: true,
|
|
165
|
-
dateBucketTimezone: true,
|
|
166
|
-
customDateBuckets: true,
|
|
167
|
-
dateBucketSubMinute: true,
|
|
168
|
-
cache: true
|
|
169
|
-
}),
|
|
170
|
-
getOrCreate: true,
|
|
171
|
-
countAndExists: true,
|
|
172
|
-
purgeByField: true,
|
|
173
|
-
purgeByFilter: true,
|
|
174
|
-
archiveByFilter: true,
|
|
175
|
-
arrayOperators: true,
|
|
176
|
-
regexFilter: true,
|
|
177
|
-
changeStreams: true,
|
|
178
|
-
lean: true,
|
|
179
|
-
lookupPopulate: true,
|
|
180
|
-
lookupCoerce: true,
|
|
181
|
-
streaming: true
|
|
182
|
-
});
|
|
183
|
-
/**
|
|
184
|
-
* Driver `ServerType`s that positively imply a session-capable, transaction-
|
|
185
|
-
* capable deployment. Verified against mongodb driver 7.x: a single-node
|
|
186
|
-
* replica set reports `RSPrimary` (topology `ReplicaSetWithPrimary`, or
|
|
187
|
-
* `Single` when reached with `directConnection`), a standalone `mongod`
|
|
188
|
-
* reports `Standalone`, and a mongos reports `Mongos`.
|
|
189
|
-
*
|
|
190
|
-
* **This is why the check reads SERVER type, not just topology type.** The
|
|
191
|
-
* pre-existing heuristic (`topology.description.type !== 'Single'`) has a
|
|
192
|
-
* false NEGATIVE: a directly-connected single-node replica set is `Single`
|
|
193
|
-
* yet runs transactions fine.
|
|
194
|
-
*/
|
|
195
|
-
const TRANSACTION_CAPABLE_SERVER_TYPES = /* @__PURE__ */ new Set([
|
|
196
|
-
"RSPrimary",
|
|
197
|
-
"RSSecondary",
|
|
198
|
-
"RSArbiter",
|
|
199
|
-
"RSOther",
|
|
200
|
-
"RSGhost",
|
|
201
|
-
"Mongos",
|
|
202
|
-
"LoadBalancer"
|
|
203
|
-
]);
|
|
204
|
-
/** Topology types that positively imply transactions regardless of server detail. */
|
|
205
|
-
const TRANSACTION_CAPABLE_TOPOLOGY_TYPES = /* @__PURE__ */ new Set([
|
|
206
|
-
"ReplicaSetWithPrimary",
|
|
207
|
-
"ReplicaSetNoPrimary",
|
|
208
|
-
"Sharded",
|
|
209
|
-
"LoadBalanced"
|
|
210
|
-
]);
|
|
211
|
-
function clientOf(connection) {
|
|
212
|
-
const probe = connection;
|
|
213
|
-
if (!probe || typeof probe !== "object") return void 0;
|
|
214
|
-
try {
|
|
215
|
-
return probe.getClient?.() ?? probe.client ?? (probe.topology !== void 0 ? probe : void 0);
|
|
216
|
-
} catch {
|
|
217
|
-
return;
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
function serverTypes(description) {
|
|
221
|
-
const servers = description.servers;
|
|
222
|
-
if (!servers) return [];
|
|
223
|
-
return (typeof servers.values === "function" ? [...servers.values()] : Object.values(servers)).map((s) => s?.type).filter((t) => typeof t === "string");
|
|
224
|
-
}
|
|
225
|
-
/**
|
|
226
|
-
* Read whether a connection's LIVE deployment supports multi-document
|
|
227
|
-
* transactions, synchronously, from the driver's SDAM topology description.
|
|
228
|
-
*
|
|
229
|
-
* Free (a few property reads against an object the driver keeps current), so
|
|
230
|
-
* it is deliberately NOT memoized — a connection that opens after the repo was
|
|
231
|
-
* constructed upgrades its own answer with no invalidation step. What IS
|
|
232
|
-
* memoized is the async {@link probeMongoCapabilities} result, which this
|
|
233
|
-
* function consults first so an exotic deployment that answered `hello` but
|
|
234
|
-
* exposes no topology still reports definitively.
|
|
235
|
-
*
|
|
236
|
-
* @returns `'yes'` / `'no'` when the topology is conclusive, `'unknown'`
|
|
237
|
-
* otherwise — never optimistic. See the module docblock on rule 3.
|
|
238
|
-
*/
|
|
239
|
-
function resolveTransactionSupport(connection) {
|
|
240
|
-
const cached = readProbeCache(connection);
|
|
241
|
-
if (cached) return cached;
|
|
242
|
-
const description = clientOf(connection)?.topology?.description;
|
|
243
|
-
if (!description) return "unknown";
|
|
244
|
-
const types = serverTypes(description);
|
|
245
|
-
if (types.some((t) => TRANSACTION_CAPABLE_SERVER_TYPES.has(t))) return "yes";
|
|
246
|
-
if (types.length > 0 && types.every((t) => t === "Standalone")) return "no";
|
|
247
|
-
const topologyType = description.type;
|
|
248
|
-
if (topologyType && TRANSACTION_CAPABLE_TOPOLOGY_TYPES.has(topologyType)) return "yes";
|
|
249
|
-
return "unknown";
|
|
250
|
-
}
|
|
251
|
-
function deriveCapabilities(support) {
|
|
252
|
-
const available = support === "yes";
|
|
253
|
-
return Object.freeze({
|
|
254
|
-
...MONGOKIT_CAPABILITIES,
|
|
255
|
-
transactionsResolution: support === "unknown" ? "unknown" : "observed",
|
|
256
|
-
transactions: available,
|
|
257
|
-
nestedTransactions: false,
|
|
258
|
-
changeStreams: available
|
|
259
|
-
});
|
|
260
|
-
}
|
|
261
|
-
/** Three possible answers ⇒ three frozen singletons; the getter allocates nothing. */
|
|
262
|
-
const DERIVED = Object.freeze({
|
|
263
|
-
yes: deriveCapabilities("yes"),
|
|
264
|
-
no: deriveCapabilities("no"),
|
|
265
|
-
unknown: deriveCapabilities("unknown")
|
|
266
|
-
});
|
|
267
|
-
/**
|
|
268
|
-
* The capability descriptor for a LIVE connection — what
|
|
269
|
-
* `Repository#capabilities` returns.
|
|
270
|
-
*
|
|
271
|
-
* Every deployment-dependent flag reflects the observed topology; everything
|
|
272
|
-
* else comes from {@link MONGOKIT_CAPABILITIES}. Cheap enough to call per
|
|
273
|
-
* access (topology read + one frozen-object lookup).
|
|
274
|
-
*/
|
|
275
|
-
function resolveMongoCapabilities(connection) {
|
|
276
|
-
return DERIVED[resolveTransactionSupport(connection)];
|
|
277
|
-
}
|
|
278
|
-
/** The three derived descriptors, for tests and for gates that want to compare identity. */
|
|
279
|
-
function capabilitiesForSupport(support) {
|
|
280
|
-
return DERIVED[support];
|
|
281
|
-
}
|
|
282
|
-
/** Definitive results only. Keyed on the connection object; never holds `'unknown'`. */
|
|
283
|
-
const PROBE_RESULTS = /* @__PURE__ */ new WeakMap();
|
|
284
|
-
/** In-flight de-duplication so N repos on one connection issue ONE `hello`. */
|
|
285
|
-
const PROBE_INFLIGHT = /* @__PURE__ */ new WeakMap();
|
|
286
|
-
function readProbeCache(connection) {
|
|
287
|
-
if (!connection || typeof connection !== "object") return void 0;
|
|
288
|
-
return PROBE_RESULTS.get(connection);
|
|
289
|
-
}
|
|
290
|
-
/** Test seam — drop memoized probe answers (a reused connection object across suites). */
|
|
291
|
-
function resetCapabilityProbeCache(connection) {
|
|
292
|
-
if (connection && typeof connection === "object") {
|
|
293
|
-
PROBE_RESULTS.delete(connection);
|
|
294
|
-
PROBE_INFLIGHT.delete(connection);
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
function withTimeout(promise, ms, onTimeout) {
|
|
298
|
-
return new Promise((resolve) => {
|
|
299
|
-
const timer = setTimeout(() => resolve(onTimeout), ms);
|
|
300
|
-
timer.unref?.();
|
|
301
|
-
promise.then((value) => {
|
|
302
|
-
clearTimeout(timer);
|
|
303
|
-
resolve(value);
|
|
304
|
-
}, () => {
|
|
305
|
-
clearTimeout(timer);
|
|
306
|
-
resolve(onTimeout);
|
|
307
|
-
});
|
|
308
|
-
});
|
|
309
|
-
}
|
|
310
|
-
/**
|
|
311
|
-
* Classify a `hello` (or legacy `isMaster`) reply.
|
|
312
|
-
*
|
|
313
|
-
* - `msg: 'isdbgrid'` ⇒ mongos ⇒ transactions (Mongo 4.2+ sharded).
|
|
314
|
-
* - `setName` ⇒ a replica-set member ⇒ transactions.
|
|
315
|
-
* - a reply that IS a hello (`isWritablePrimary` / `ismaster` present) with
|
|
316
|
-
* neither marker ⇒ standalone ⇒ **no**. Verified: a memory-server standalone
|
|
317
|
-
* returns `{ isWritablePrimary: true }` and nothing else.
|
|
318
|
-
* - anything unrecognised ⇒ `'unknown'`. A reply we cannot parse is not a yes.
|
|
319
|
-
*/
|
|
320
|
-
function classifyHelloReply(reply) {
|
|
321
|
-
if (!reply || typeof reply !== "object") return "unknown";
|
|
322
|
-
const { msg, setName } = reply;
|
|
323
|
-
if (msg === "isdbgrid") return "yes";
|
|
324
|
-
if (typeof setName === "string" && setName.length > 0) return "yes";
|
|
325
|
-
return "isWritablePrimary" in reply || "ismaster" in reply ? "no" : "unknown";
|
|
326
|
-
}
|
|
327
|
-
async function runProbe(connection, timeoutMs) {
|
|
328
|
-
if (connection.readyState === 2 && typeof connection.asPromise === "function") await withTimeout(connection.asPromise().then(() => void 0, () => void 0), timeoutMs, void 0);
|
|
329
|
-
const fromTopology = resolveTransactionSupport(connection);
|
|
330
|
-
if (fromTopology !== "unknown") return fromTopology;
|
|
331
|
-
const admin = connection.db?.admin?.();
|
|
332
|
-
if (!admin) return "unknown";
|
|
333
|
-
return classifyHelloReply(await withTimeout(admin.command({ hello: 1 }).catch(async () => admin.command({ isMaster: 1 }).catch(() => void 0)), timeoutMs, void 0));
|
|
334
|
-
}
|
|
335
|
-
/**
|
|
336
|
-
* Resolve a connection's real capabilities, waiting for (and if necessary
|
|
337
|
-
* asking) the deployment. Memoized per connection: N repositories and N
|
|
338
|
-
* kernels on one connection issue at most ONE `hello`.
|
|
339
|
-
*
|
|
340
|
-
* Hosts do not have to call this — `Repository#capabilities` reads the live
|
|
341
|
-
* topology synchronously, which is already conclusive once
|
|
342
|
-
* `await mongoose.connect()` has returned (the Kernel Construction Standard's
|
|
343
|
-
* Describe → **Bind** ordering guarantees that). It exists for the cases the
|
|
344
|
-
* sync read cannot cover: binding while a connection is still opening, or a
|
|
345
|
-
* deployment whose driver surfaces no topology description. Calling it makes
|
|
346
|
-
* the SUBSEQUENT synchronous reads definitive, so nothing else needs rewiring:
|
|
347
|
-
*
|
|
348
|
-
* ```ts
|
|
349
|
-
* await probeMongoCapabilities(connection); // once, before bind
|
|
350
|
-
* const engine = defineWallet().bind(connection); // gate now sees the truth
|
|
351
|
-
* ```
|
|
352
|
-
*
|
|
353
|
-
* Only definitive answers are cached — an `'unknown'` may be re-probed later
|
|
354
|
-
* (the deployment may simply not have been up yet).
|
|
355
|
-
*/
|
|
356
|
-
async function probeMongoCapabilities(connection, options = {}) {
|
|
357
|
-
if (!connection || typeof connection !== "object") return DERIVED.unknown;
|
|
358
|
-
const key = connection;
|
|
359
|
-
const cached = PROBE_RESULTS.get(key);
|
|
360
|
-
if (cached) return DERIVED[cached];
|
|
361
|
-
const inflight = PROBE_INFLIGHT.get(key);
|
|
362
|
-
if (inflight) return DERIVED[await inflight];
|
|
363
|
-
const run = runProbe(connection, options.timeoutMs ?? 2e3).catch(() => "unknown").then((support) => {
|
|
364
|
-
if (support !== "unknown") PROBE_RESULTS.set(key, support);
|
|
365
|
-
PROBE_INFLIGHT.delete(key);
|
|
366
|
-
return support;
|
|
367
|
-
});
|
|
368
|
-
PROBE_INFLIGHT.set(key, run);
|
|
369
|
-
return DERIVED[await run];
|
|
370
|
-
}
|
|
371
|
-
//#endregion
|
|
372
132
|
//#region src/operations.ts
|
|
373
133
|
/**
|
|
374
134
|
* The registry. Order intentionally groups by category for readability.
|
|
@@ -528,123 +288,32 @@ function applyExecutionHints(aggregation, hints) {
|
|
|
528
288
|
if (!hints) return;
|
|
529
289
|
if (hints.allowDiskUse) aggregation.allowDiskUse(true);
|
|
530
290
|
if (typeof hints.maxTimeMs === "number" && hints.maxTimeMs > 0) aggregation.option({ maxTimeMS: hints.maxTimeMs });
|
|
531
|
-
if (hints.indexHint
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
/** Operators whose operand is a single scalar to cast. */
|
|
536
|
-
const SCALAR_OPS = /* @__PURE__ */ new Set([
|
|
537
|
-
"$eq",
|
|
538
|
-
"$ne",
|
|
539
|
-
"$gt",
|
|
540
|
-
"$gte",
|
|
541
|
-
"$lt",
|
|
542
|
-
"$lte"
|
|
543
|
-
]);
|
|
544
|
-
/** Operators whose operand is an array of scalars to cast element-wise. */
|
|
545
|
-
const ARRAY_OPS = /* @__PURE__ */ new Set(["$in", "$nin"]);
|
|
546
|
-
/** Logical operators whose operand is an ARRAY of sub-filters. */
|
|
547
|
-
const LOGICAL_ARRAY_OPS = /* @__PURE__ */ new Set([
|
|
548
|
-
"$and",
|
|
549
|
-
"$or",
|
|
550
|
-
"$nor"
|
|
551
|
-
]);
|
|
552
|
-
/** Logical operators whose operand is a SINGLE nested sub-filter. */
|
|
553
|
-
const LOGICAL_OBJECT_OPS = /* @__PURE__ */ new Set(["$not"]);
|
|
554
|
-
function isPlainObject(value) {
|
|
555
|
-
return value !== null && typeof value === "object" && !Array.isArray(value) && !(value instanceof Date);
|
|
556
|
-
}
|
|
557
|
-
/**
|
|
558
|
-
* Cast one value through a schema path. Returns the original value when the
|
|
559
|
-
* path is unknown, the value is nullish, or the cast throws — never raises.
|
|
560
|
-
*/
|
|
561
|
-
function castValue(schema, field, value) {
|
|
562
|
-
if (value === null || value === void 0) return value;
|
|
563
|
-
if (typeof value === "object" && !(value instanceof Date)) return value;
|
|
564
|
-
let schemaType;
|
|
565
|
-
try {
|
|
566
|
-
schemaType = schema.path(field);
|
|
567
|
-
} catch {
|
|
568
|
-
return value;
|
|
569
|
-
}
|
|
570
|
-
if (!schemaType || typeof schemaType.cast !== "function") return value;
|
|
571
|
-
try {
|
|
572
|
-
return schemaType.cast(value);
|
|
573
|
-
} catch {
|
|
574
|
-
return value;
|
|
291
|
+
if (hints.indexHint === void 0) return;
|
|
292
|
+
if (isExpectedIndex(hints.indexHint)) {
|
|
293
|
+
assertExpectedIndex(aggregation.model(), hints.indexHint.leadingKeys);
|
|
294
|
+
return;
|
|
575
295
|
}
|
|
296
|
+
aggregation.option({ hint: hints.indexHint });
|
|
576
297
|
}
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
* don't understand (`$regex`, `$exists`, `$size`, `$elemMatch`, …) alone.
|
|
580
|
-
*/
|
|
581
|
-
function castOperatorObject(schema, field, ops) {
|
|
582
|
-
let changed = false;
|
|
583
|
-
const out = {};
|
|
584
|
-
for (const [op, operand] of Object.entries(ops)) if (SCALAR_OPS.has(op)) {
|
|
585
|
-
const next = castValue(schema, field, operand);
|
|
586
|
-
out[op] = next;
|
|
587
|
-
if (next !== operand) changed = true;
|
|
588
|
-
} else if (ARRAY_OPS.has(op) && Array.isArray(operand)) {
|
|
589
|
-
const arr = operand.map((entry) => castValue(schema, field, entry));
|
|
590
|
-
out[op] = arr;
|
|
591
|
-
if (arr.some((v, i) => v !== operand[i])) changed = true;
|
|
592
|
-
} else out[op] = operand;
|
|
593
|
-
return changed ? out : ops;
|
|
298
|
+
function isExpectedIndex(hint) {
|
|
299
|
+
return typeof hint === "object" && hint !== null && Array.isArray(hint.leadingKeys) && hint.leadingKeys.length > 0;
|
|
594
300
|
}
|
|
301
|
+
const checked = /* @__PURE__ */ new WeakMap();
|
|
595
302
|
/**
|
|
596
|
-
*
|
|
597
|
-
*
|
|
598
|
-
*
|
|
599
|
-
*
|
|
600
|
-
*
|
|
601
|
-
* in code (already correctly typed) and carry no schema paths to resolve.
|
|
602
|
-
*
|
|
603
|
-
* @example
|
|
604
|
-
* ```ts
|
|
605
|
-
* // organizationId is an ObjectId column; arc supplies the scope id as a string
|
|
606
|
-
* castFilterToSchema({ organizationId: '6a6a…' }, Order.schema)
|
|
607
|
-
* // → { organizationId: ObjectId('6a6a…') }
|
|
608
|
-
* ```
|
|
303
|
+
* Throw unless the model DECLARES an index whose key starts with `leadingKeys`, directly or after
|
|
304
|
+
* one leading scope field (a tenant / branch column the query pins by equality). Forcing such a
|
|
305
|
+
* description as a `hint` was wrong twice over: Mongo requires an index with EXACTLY that key, so a
|
|
306
|
+
* longer one failed `BadValue`; and a hint overrides the planner. The planner picks; this only
|
|
307
|
+
* guarantees the declared expectation holds. Checked once per model and key set.
|
|
609
308
|
*/
|
|
610
|
-
function
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
const
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
out[key] = arr;
|
|
619
|
-
if (arr.some((v, i) => v !== value[i])) changed = true;
|
|
620
|
-
continue;
|
|
621
|
-
}
|
|
622
|
-
if (LOGICAL_OBJECT_OPS.has(key) && isPlainObject(value)) {
|
|
623
|
-
const nested = castFilterToSchema(value, schema);
|
|
624
|
-
out[key] = nested;
|
|
625
|
-
if (nested !== value) changed = true;
|
|
626
|
-
continue;
|
|
627
|
-
}
|
|
628
|
-
if (key.startsWith("$")) {
|
|
629
|
-
out[key] = value;
|
|
630
|
-
continue;
|
|
631
|
-
}
|
|
632
|
-
if (isPlainObject(value)) {
|
|
633
|
-
const keys = Object.keys(value);
|
|
634
|
-
if (keys.length > 0 && keys.every((k) => k.startsWith("$"))) {
|
|
635
|
-
const nextOps = castOperatorObject(schema, key, value);
|
|
636
|
-
out[key] = nextOps;
|
|
637
|
-
if (nextOps !== value) changed = true;
|
|
638
|
-
continue;
|
|
639
|
-
}
|
|
640
|
-
out[key] = value;
|
|
641
|
-
continue;
|
|
642
|
-
}
|
|
643
|
-
const next = castValue(schema, key, value);
|
|
644
|
-
out[key] = next;
|
|
645
|
-
if (next !== value) changed = true;
|
|
646
|
-
}
|
|
647
|
-
return changed ? out : filter;
|
|
309
|
+
function assertExpectedIndex(model, leadingKeys) {
|
|
310
|
+
const id = leadingKeys.join(",");
|
|
311
|
+
const done = checked.get(model);
|
|
312
|
+
if (done?.has(id)) return;
|
|
313
|
+
const keys = model.schema.indexes().map(([fields]) => Object.keys(fields));
|
|
314
|
+
const startsWith = (key, at) => leadingKeys.every((k, i) => key[at + i] === k);
|
|
315
|
+
if (!keys.some((key) => startsWith(key, 0) || startsWith(key, 1))) throw new Error(`[mongokit] ${model.modelName}: an aggregation expects an index leading with (${id}), but none is declared — declared: ${keys.map((k) => `(${k.join(",")})`).join(" ") || "none"}. Declare one, or drop the hint.`);
|
|
316
|
+
checked.set(model, (done ?? /* @__PURE__ */ new Set()).add(id));
|
|
648
317
|
}
|
|
649
318
|
//#endregion
|
|
650
319
|
//#region src/actions/aggregate-ir/normalize.ts
|
|
@@ -806,8 +475,216 @@ function formatForUnit(unit) {
|
|
|
806
475
|
*/
|
|
807
476
|
function compileFilterToMongoExpr(input) {
|
|
808
477
|
if (input === void 0 || input === null) return true;
|
|
809
|
-
if (
|
|
810
|
-
|
|
478
|
+
if (isFilter(input)) return compile(input);
|
|
479
|
+
if (typeof input === "boolean") return input;
|
|
480
|
+
/**
|
|
481
|
+
* Anything else must be an aggregation EXPRESSION, and an aggregation
|
|
482
|
+
* expression's operator keys are `$`-prefixed (`$eq`, `$and`, `$gt`).
|
|
483
|
+
*
|
|
484
|
+
* This used to `return input` for everything that was not Filter IR,
|
|
485
|
+
* described as "already an expression — pass through unchanged". A plain
|
|
486
|
+
* query object is not an expression: `{ status: { eq: 'paid' } }` reaches
|
|
487
|
+
* `$cond` as a non-empty object, which MongoDB evaluates as TRUTHY. So a
|
|
488
|
+
* measure's `where` written in query syntax silently matched every row and
|
|
489
|
+
* the filtered aggregate came back equal to the unfiltered one, with nothing
|
|
490
|
+
* raised.
|
|
491
|
+
*
|
|
492
|
+
* That syntax is not imaginary — it is exactly what `AggRequest.filter`
|
|
493
|
+
* accepts, because `compileFilterToMongo` runs `expandShorthands` over it.
|
|
494
|
+
* Two filter surfaces on one request object, one of which honoured the
|
|
495
|
+
* syntax and the other quietly ignored it.
|
|
496
|
+
*
|
|
497
|
+
* Refused rather than translated: a partial query-to-expression translator
|
|
498
|
+
* would get the common operators right and fail the same silent way on the
|
|
499
|
+
* rest, which is the failure being fixed.
|
|
500
|
+
*/
|
|
501
|
+
if (typeof input === "object") {
|
|
502
|
+
const keys = Object.keys(input);
|
|
503
|
+
/**
|
|
504
|
+
* Normalization is attempted FIRST, before the "all `$` keys means it is
|
|
505
|
+
* already an expression" fallback — because `{ $and: [...] }` satisfies
|
|
506
|
+
* both readings. As a query it is a conjunction of query objects; as an
|
|
507
|
+
* expression `$and` is a real operator whose operands would be those same
|
|
508
|
+
* objects, evaluated as TRUTHY. Taking the expression reading first made
|
|
509
|
+
* every `$and`/`$or` filter match all rows.
|
|
510
|
+
*
|
|
511
|
+
* Trying the query reading first disambiguates correctly: a genuine
|
|
512
|
+
* expression (`{ $eq: ['$a', 1] }`, or `$and` over expressions) fails to
|
|
513
|
+
* normalize — its operands are not field/operator objects — and falls
|
|
514
|
+
* through unchanged.
|
|
515
|
+
*/
|
|
516
|
+
const normalized = queryToFilterIr(input);
|
|
517
|
+
if (normalized) return compile(normalized);
|
|
518
|
+
if (keys.length > 0 && keys.every((k) => k.startsWith("$"))) return input;
|
|
519
|
+
throw createError(400, `mongokit/filter: could not express this \`where\` as an aggregation condition. Accepted: query syntax (\`{ field: { gte: 1 } }\`, the same language \`filter\` and \`lookup.where\` take), Filter IR (\`{ op, field, value }\`), or a MongoDB expression (\`{ \$eq: ['\$field', value] }\`). Got ${keys.length === 0 ? "an empty object" : `an object keyed by ${keys.map((k) => `'${k}'`).join(", ")}`}. \`mod\` and \`$where\` have no expression form and are refused by name. Refused rather than passed through: evaluated as an expression a plain object is always TRUE, so the filter would silently match every row.`);
|
|
520
|
+
}
|
|
521
|
+
return input;
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Operators a query object may use, mapped to their Filter IR op.
|
|
525
|
+
*
|
|
526
|
+
* Exactly the set `compileFilterToMongo`'s `expandShorthands` accepts, minus
|
|
527
|
+
* the ones the IR cannot express — so this is COMPLETE over its input, not a
|
|
528
|
+
* best-effort subset. Anything outside it reaches the refusal below rather
|
|
529
|
+
* than being quietly dropped.
|
|
530
|
+
*
|
|
531
|
+
* `mod` is the one shorthand with no IR node. It is refused by name, because
|
|
532
|
+
* "translated everything except the operator you used" is the failure this
|
|
533
|
+
* whole function exists to stop.
|
|
534
|
+
*/
|
|
535
|
+
/**
|
|
536
|
+
* Query operators this translates, and the Filter IR node each becomes.
|
|
537
|
+
*
|
|
538
|
+
* Exactly the set `compileFilterToMongo`'s `expandShorthands` accepts, minus
|
|
539
|
+
* the ones the IR cannot express — so the translation is COMPLETE over its
|
|
540
|
+
* input rather than a best-effort subset. Anything outside reaches the refusal
|
|
541
|
+
* below instead of being quietly dropped.
|
|
542
|
+
*
|
|
543
|
+
* Each node is BUILT, not spread from a common shape: the IR is not uniform —
|
|
544
|
+
* `in`/`nin` carry `values`, `exists` carries `exists`, `regex` carries
|
|
545
|
+
* `pattern`/`flags`. Emitting `value` for all of them compiled without
|
|
546
|
+
* complaint and then matched nothing, which the parity suite caught and a
|
|
547
|
+
* hand-written expectation would not have.
|
|
548
|
+
*/
|
|
549
|
+
const QUERY_OPS = {
|
|
550
|
+
eq: (field, value) => ({
|
|
551
|
+
op: "eq",
|
|
552
|
+
field,
|
|
553
|
+
value
|
|
554
|
+
}),
|
|
555
|
+
ne: (field, value) => ({
|
|
556
|
+
op: "ne",
|
|
557
|
+
field,
|
|
558
|
+
value
|
|
559
|
+
}),
|
|
560
|
+
gt: (field, value) => ({
|
|
561
|
+
op: "gt",
|
|
562
|
+
field,
|
|
563
|
+
value
|
|
564
|
+
}),
|
|
565
|
+
gte: (field, value) => ({
|
|
566
|
+
op: "gte",
|
|
567
|
+
field,
|
|
568
|
+
value
|
|
569
|
+
}),
|
|
570
|
+
lt: (field, value) => ({
|
|
571
|
+
op: "lt",
|
|
572
|
+
field,
|
|
573
|
+
value
|
|
574
|
+
}),
|
|
575
|
+
lte: (field, value) => ({
|
|
576
|
+
op: "lte",
|
|
577
|
+
field,
|
|
578
|
+
value
|
|
579
|
+
}),
|
|
580
|
+
in: (field, operand) => ({
|
|
581
|
+
op: "in",
|
|
582
|
+
field,
|
|
583
|
+
values: toArray(operand)
|
|
584
|
+
}),
|
|
585
|
+
nin: (field, operand) => ({
|
|
586
|
+
op: "nin",
|
|
587
|
+
field,
|
|
588
|
+
values: toArray(operand)
|
|
589
|
+
}),
|
|
590
|
+
exists: (field, operand) => ({
|
|
591
|
+
op: "exists",
|
|
592
|
+
field,
|
|
593
|
+
exists: operand !== false
|
|
594
|
+
}),
|
|
595
|
+
regex: (field, operand) => buildRegex(field, operand)
|
|
596
|
+
};
|
|
597
|
+
for (const name of Object.keys(QUERY_OPS)) QUERY_OPS[`$${name}`] = QUERY_OPS[name];
|
|
598
|
+
function toArray(operand) {
|
|
599
|
+
return Array.isArray(operand) ? [...operand] : [operand];
|
|
600
|
+
}
|
|
601
|
+
/** `regex` accepts a string, `{ $regex, $options }`, or a real RegExp. */
|
|
602
|
+
function buildRegex(field, operand) {
|
|
603
|
+
if (operand instanceof RegExp) return operand.flags ? {
|
|
604
|
+
op: "regex",
|
|
605
|
+
field,
|
|
606
|
+
pattern: operand.source,
|
|
607
|
+
flags: operand.flags
|
|
608
|
+
} : {
|
|
609
|
+
op: "regex",
|
|
610
|
+
field,
|
|
611
|
+
pattern: operand.source
|
|
612
|
+
};
|
|
613
|
+
if (operand !== null && typeof operand === "object") {
|
|
614
|
+
const o = operand;
|
|
615
|
+
const flags = typeof o.$options === "string" ? o.$options : void 0;
|
|
616
|
+
return flags ? {
|
|
617
|
+
op: "regex",
|
|
618
|
+
field,
|
|
619
|
+
pattern: String(o.$regex ?? ""),
|
|
620
|
+
flags
|
|
621
|
+
} : {
|
|
622
|
+
op: "regex",
|
|
623
|
+
field,
|
|
624
|
+
pattern: String(o.$regex ?? "")
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
return {
|
|
628
|
+
op: "regex",
|
|
629
|
+
field,
|
|
630
|
+
pattern: String(operand)
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
const LOGICAL = {
|
|
634
|
+
$and: "and",
|
|
635
|
+
$or: "or"
|
|
636
|
+
};
|
|
637
|
+
/** `{ field: { gte: 1, lte: 9 } }` and `{ a: 1, b: 2 }` are both conjunctions. */
|
|
638
|
+
function allOf(children) {
|
|
639
|
+
if (children.length === 0) return { op: "true" };
|
|
640
|
+
if (children.length === 1) return children[0];
|
|
641
|
+
return {
|
|
642
|
+
op: "and",
|
|
643
|
+
children
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* Translate a plain query object into Filter IR, or return `null` when it uses
|
|
648
|
+
* anything outside {@link QUERY_OPS}.
|
|
649
|
+
*
|
|
650
|
+
* `null` rather than a throw so the caller owns the error message — it has the
|
|
651
|
+
* context (which surface, what was passed) that makes the refusal actionable.
|
|
652
|
+
*/
|
|
653
|
+
function queryToFilterIr(query) {
|
|
654
|
+
const children = [];
|
|
655
|
+
for (const [key, value] of Object.entries(query)) {
|
|
656
|
+
const logical = LOGICAL[key];
|
|
657
|
+
if (logical) {
|
|
658
|
+
if (!Array.isArray(value)) return null;
|
|
659
|
+
const subs = [];
|
|
660
|
+
for (const entry of value) {
|
|
661
|
+
if (entry === null || typeof entry !== "object") return null;
|
|
662
|
+
const sub = queryToFilterIr(entry);
|
|
663
|
+
if (!sub) return null;
|
|
664
|
+
subs.push(sub);
|
|
665
|
+
}
|
|
666
|
+
children.push({
|
|
667
|
+
op: logical,
|
|
668
|
+
children: subs
|
|
669
|
+
});
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
672
|
+
if (key.startsWith("$")) return null;
|
|
673
|
+
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
674
|
+
const ops = Object.keys(value);
|
|
675
|
+
if (ops.length > 0 && ops.some((o) => o.startsWith("$") || SHORTHAND_OPS.has(o))) {
|
|
676
|
+
if (!ops.every((o) => o in QUERY_OPS)) return null;
|
|
677
|
+
for (const [op, operand] of Object.entries(value)) children.push(QUERY_OPS[op](key, operand));
|
|
678
|
+
continue;
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
children.push({
|
|
682
|
+
op: "eq",
|
|
683
|
+
field: key,
|
|
684
|
+
value
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
return allOf(children);
|
|
811
688
|
}
|
|
812
689
|
function compile(filter) {
|
|
813
690
|
switch (filter.op) {
|
|
@@ -825,7 +702,25 @@ function compile(filter) {
|
|
|
825
702
|
case "nin":
|
|
826
703
|
if (filter.values.length === 0) return true;
|
|
827
704
|
return { $not: [{ $in: [`$${filter.field}`, [...filter.values]] }] };
|
|
828
|
-
case "exists":
|
|
705
|
+
case "exists": {
|
|
706
|
+
/**
|
|
707
|
+
* SQL parity: `exists: false` matches NULL and MISSING alike (`IS
|
|
708
|
+
* NULL`); `exists: true` matches neither (`IS NOT NULL`).
|
|
709
|
+
*
|
|
710
|
+
* This was `$ne: [field, null]` / `$eq: [field, null]`, on the stated
|
|
711
|
+
* grounds that "mongo treats [null and missing] the same way for missing
|
|
712
|
+
* fields". It does not: a missing path in an expression is MISSING, not
|
|
713
|
+
* null, and `$ne: ['$absent', null]` is TRUE. So `exists: true` matched
|
|
714
|
+
* every document including the ones without the field, and
|
|
715
|
+
* `exists: false` matched none — the predicate inverted on exactly the
|
|
716
|
+
* documents it was asked about.
|
|
717
|
+
*
|
|
718
|
+
* `$type` is the only operator that distinguishes the three states, and
|
|
719
|
+
* it names both of the ones SQL folds together.
|
|
720
|
+
*/
|
|
721
|
+
const nullish = { $in: [{ $type: `$${filter.field}` }, ["missing", "null"]] };
|
|
722
|
+
return filter.exists ? { $not: [nullish] } : nullish;
|
|
723
|
+
}
|
|
829
724
|
case "like": return { $regexMatch: {
|
|
830
725
|
input: `$${filter.field}`,
|
|
831
726
|
regex: likeToRegexPattern(filter.pattern),
|
|
@@ -983,20 +878,26 @@ function buildAggPipeline(req, schema) {
|
|
|
983
878
|
for (const alias of bucketAliases) addFields[bucketInternalKey(alias)] = compileDateBucket(req.dateBuckets[alias]);
|
|
984
879
|
stages.push({ $addFields: addFields });
|
|
985
880
|
}
|
|
986
|
-
const
|
|
881
|
+
const groupId = groupCols.length === 0 && bucketAliases.length === 0 ? null : (() => {
|
|
987
882
|
const id = {};
|
|
988
883
|
for (const field of groupCols) id[safeIdKey(field)] = `$${field}`;
|
|
989
884
|
for (const alias of bucketAliases) id[alias] = `$${bucketInternalKey(alias)}`;
|
|
990
885
|
return id;
|
|
991
|
-
})()
|
|
886
|
+
})();
|
|
887
|
+
const groupStage = { _id: groupId };
|
|
992
888
|
const distinctSetAliases = [];
|
|
993
889
|
const percentileAliases = [];
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
890
|
+
const groupedDistinct = req.countDistinctStrategy === "grouped";
|
|
891
|
+
const distinctAliases = Object.entries(req.measures).filter(([, m]) => m.op === "countDistinct").map(([alias]) => alias);
|
|
892
|
+
if (groupedDistinct && distinctAliases.length > 0) appendGroupedDistinctStages(stages, req, groupId, distinctAliases);
|
|
893
|
+
else {
|
|
894
|
+
for (const [alias, measure] of Object.entries(req.measures)) {
|
|
895
|
+
groupStage[alias] = compileMeasure(measure).groupExpr;
|
|
896
|
+
if (measure.op === "countDistinct") distinctSetAliases.push(alias);
|
|
897
|
+
else if (measure.op === "percentile") percentileAliases.push(alias);
|
|
898
|
+
}
|
|
899
|
+
stages.push({ $group: groupStage });
|
|
998
900
|
}
|
|
999
|
-
stages.push({ $group: groupStage });
|
|
1000
901
|
if (distinctSetAliases.length > 0) {
|
|
1001
902
|
const addFields = {};
|
|
1002
903
|
for (const alias of distinctSetAliases) addFields[alias] = { $size: { $ifNull: [`$${alias}`, []] } };
|
|
@@ -1065,12 +966,108 @@ function validateTopN(topN, groupCols, bucketAliases, measures) {
|
|
|
1065
966
|
* - `'dense_rank'` → `$denseRank` (no gaps)
|
|
1066
967
|
* - `'row_number'` → `$documentNumber` (each row unique)
|
|
1067
968
|
*/
|
|
969
|
+
/**
|
|
970
|
+
* Accumulators that survive being computed over a FINER partition and then
|
|
971
|
+
* combined — the whole constraint on the grouped strategy.
|
|
972
|
+
*
|
|
973
|
+
* Pre-grouping by the distinct field splits every group into one sub-group per
|
|
974
|
+
* distinct value, so each remaining measure must answer the same over the split
|
|
975
|
+
* as it did whole. Summing is associative over a partition, and so are min and
|
|
976
|
+
* max, so those four are exact.
|
|
977
|
+
*
|
|
978
|
+
* `avg` is not (the mean of means is not the mean); `percentile` and `stddev`
|
|
979
|
+
* are not; and a SECOND `countDistinct` would need the partition to carry both
|
|
980
|
+
* fields, which multiplies the rows and breaks the first count. Those are
|
|
981
|
+
* REFUSED rather than approximated — a distinct count that is quietly wrong is
|
|
982
|
+
* the exact failure this strategy exists to avoid.
|
|
983
|
+
*/
|
|
984
|
+
const REAGGREGATABLE = {
|
|
985
|
+
count: "$sum",
|
|
986
|
+
sum: "$sum",
|
|
987
|
+
min: "$min",
|
|
988
|
+
max: "$max"
|
|
989
|
+
};
|
|
990
|
+
/** Internal `_id` slot holding one distinct value during the pre-group. */
|
|
991
|
+
const distinctIdKey = (alias) => `__dv_${alias}`;
|
|
992
|
+
/**
|
|
993
|
+
* Compile `countDistinct` as pre-group + count instead of accumulate + size.
|
|
994
|
+
*
|
|
995
|
+
* `$addToSet` holds every distinct value of a group in that group's accumulator,
|
|
996
|
+
* so memory grows with cardinality and MongoDB caps it at 100MB. Grouping by the
|
|
997
|
+
* value instead turns each distinct value into a ROW, and rows spill. Nothing
|
|
998
|
+
* accumulates, so cardinality stops being a memory question.
|
|
999
|
+
*
|
|
1000
|
+
* The two semantics that had to be matched exactly, both measured against
|
|
1001
|
+
* `$addToSet` rather than assumed:
|
|
1002
|
+
*
|
|
1003
|
+
* - a MISSING field is not a distinct value, but an explicit `null` IS. A
|
|
1004
|
+
* naive `$group` disagrees on both, and on a group whose field is always
|
|
1005
|
+
* missing it answers 1 where `$addToSet` answers 0. The `$type` check is
|
|
1006
|
+
* what separates them: a missing field leaves the `_id` slot absent, an
|
|
1007
|
+
* explicit null leaves it `null`.
|
|
1008
|
+
* - `where` excludes a row from the COUNT without excluding it from the other
|
|
1009
|
+
* measures. `compileMeasure` already emits `$$REMOVE` for a non-matching
|
|
1010
|
+
* row, which lands it in the same "missing" slot — so the same `$type`
|
|
1011
|
+
* check covers the filtered case with no extra branch.
|
|
1012
|
+
*/
|
|
1013
|
+
function appendGroupedDistinctStages(stages, req, groupId, distinctAliases) {
|
|
1014
|
+
if (distinctAliases.length > 1) throw createError(400, `mongokit/aggregate: countDistinctStrategy 'grouped' supports one countDistinct per request, got ${distinctAliases.length} (${distinctAliases.join(", ")}). Two distinct fields would have to share one partition, which multiplies the rows and makes both counts wrong. Issue them as separate requests, or use the default 'accumulator'.`);
|
|
1015
|
+
const distinctAlias = distinctAliases[0];
|
|
1016
|
+
const blocked = Object.entries(req.measures).filter(([alias, m]) => alias !== distinctAlias && !REAGGREGATABLE[m.op]);
|
|
1017
|
+
if (blocked.length > 0) throw createError(400, `mongokit/aggregate: countDistinctStrategy 'grouped' cannot combine with ${blocked.map(([a, m]) => `'${a}' (${m.op})`).join(", ")}. Pre-grouping by the distinct field splits each group, and only count/sum/min/max survive being recombined — an ${blocked[0][1].op} over the split would be silently wrong. Compute it in a separate request, or use the default 'accumulator' strategy.`);
|
|
1018
|
+
const dvKey = distinctIdKey(distinctAlias);
|
|
1019
|
+
const preId = { ...groupId ?? {} };
|
|
1020
|
+
preId[dvKey] = compileMeasure(req.measures[distinctAlias]).groupExpr;
|
|
1021
|
+
preId[dvKey] = preId[dvKey].$addToSet;
|
|
1022
|
+
const preGroup = { _id: preId };
|
|
1023
|
+
for (const [alias, measure] of Object.entries(req.measures)) {
|
|
1024
|
+
if (alias === distinctAlias) continue;
|
|
1025
|
+
preGroup[alias] = compileMeasure(measure).groupExpr;
|
|
1026
|
+
}
|
|
1027
|
+
stages.push({ $group: preGroup });
|
|
1028
|
+
const regroup = { _id: groupId === null ? null : Object.fromEntries(Object.keys(groupId).map((k) => [k, `$_id.${k}`])) };
|
|
1029
|
+
regroup[distinctAlias] = { $sum: { $cond: [
|
|
1030
|
+
{ $eq: [{ $type: `$_id.${dvKey}` }, "missing"] },
|
|
1031
|
+
0,
|
|
1032
|
+
1
|
|
1033
|
+
] } };
|
|
1034
|
+
for (const [alias, measure] of Object.entries(req.measures)) {
|
|
1035
|
+
if (alias === distinctAlias) continue;
|
|
1036
|
+
regroup[alias] = { [REAGGREGATABLE[measure.op]]: `$${alias}` };
|
|
1037
|
+
}
|
|
1038
|
+
stages.push({ $group: regroup });
|
|
1039
|
+
}
|
|
1068
1040
|
function appendTopNStages(stages, topN) {
|
|
1069
1041
|
const partitionList = Array.isArray(topN.partitionBy) ? topN.partitionBy : [topN.partitionBy];
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1042
|
+
/**
|
|
1043
|
+
* A compound partition is a TUPLE, and a tuple's identity cannot be a string.
|
|
1044
|
+
*
|
|
1045
|
+
* This built the key by `$toString`-ing each value and joining with U+0001,
|
|
1046
|
+
* mapping null to the literal `'__NULL__'`. Both halves lose information, and
|
|
1047
|
+
* the loss merges partitions that are not the same — a value CONTAINING the
|
|
1048
|
+
* separator, or a document genuinely holding the string `'__NULL__'`, lands
|
|
1049
|
+
* on another partition's key. Two unrelated partitions then rank as one, so
|
|
1050
|
+
* `topN` returns rows that are not the top N of anything. Measured: four rows
|
|
1051
|
+
* across two partitions came back ranked 1, 2, 3, 4.
|
|
1052
|
+
*
|
|
1053
|
+
* A DOCUMENT expression compares by value, field by field, with no
|
|
1054
|
+
* serialization in between, so distinct tuples stay distinct whatever they
|
|
1055
|
+
* contain. An array would read more naturally and is not available: Mongo
|
|
1056
|
+
* refuses it outright with "An expression used to partition cannot evaluate
|
|
1057
|
+
* to value of type array".
|
|
1058
|
+
*
|
|
1059
|
+
* Keys are positional (`k0`, `k1`, ...) rather than the field names, because
|
|
1060
|
+
* a partition field may be a dotted path and a dot is not legal in an
|
|
1061
|
+
* expression document's key. They are internal to this stage and never
|
|
1062
|
+
* surface in output.
|
|
1063
|
+
*
|
|
1064
|
+
* `$ifNull` to null keeps arity fixed, so a missing field and an explicit
|
|
1065
|
+
* null partition together the way SQL's `PARTITION BY` groups NULLs, while a
|
|
1066
|
+
* real `'__NULL__'` string stays separate from both.
|
|
1067
|
+
*
|
|
1068
|
+
* Single-key partitions keep the bare path: same semantics, cleaner plan.
|
|
1069
|
+
*/
|
|
1070
|
+
const partitionExpr = partitionList.length === 1 ? `$${partitionList[0]}` : Object.fromEntries(partitionList.map((field, i) => [`k${i}`, { $ifNull: [`$${field}`, null] }]));
|
|
1074
1071
|
const tiesOp = topN.ties === "dense_rank" ? "$denseRank" : topN.ties === "row_number" ? "$documentNumber" : "$rank";
|
|
1075
1072
|
const RANK_FIELD = "__topNRank";
|
|
1076
1073
|
stages.push({ $setWindowFields: {
|
|
@@ -1308,8 +1305,22 @@ async function executeAgg(Model, req, options = {}) {
|
|
|
1308
1305
|
* keyset predicate runs AFTER the post-group `$project` stage, which
|
|
1309
1306
|
* is when the output column names exist.
|
|
1310
1307
|
*/
|
|
1311
|
-
|
|
1312
|
-
|
|
1308
|
+
/**
|
|
1309
|
+
* The aggregate cursor is signed by the SAME key as the find-based one.
|
|
1310
|
+
*
|
|
1311
|
+
* Two cursor codecs are exposed on the wire, and a deployment that signed only
|
|
1312
|
+
* the one on `getAll` would still hand out a tamperable position on every
|
|
1313
|
+
* aggregate keyset endpoint. A guarantee with an unsigned second door is not a
|
|
1314
|
+
* guarantee — it is a guarantee-shaped thing that reviewers stop checking.
|
|
1315
|
+
*
|
|
1316
|
+
* The signature wraps repo-core's kit-neutral codec rather than living inside
|
|
1317
|
+
* it: repo-core's codec is browser-safe by contract and `node:crypto` is not.
|
|
1318
|
+
*/
|
|
1319
|
+
function encodeAggCursor$1(row, sort, secret) {
|
|
1320
|
+
return attachSignature(encodeAggCursor(row, sort), resolveCursorSecrets(secret));
|
|
1321
|
+
}
|
|
1322
|
+
function decodeAggCursor$1(cursor, secret) {
|
|
1323
|
+
return decodeAggCursor(verifySignature(cursor, resolveCursorSecrets(secret)), "mongokit");
|
|
1313
1324
|
}
|
|
1314
1325
|
/**
|
|
1315
1326
|
* Build a `$match` stage that selects rows AFTER the cursor row given
|
|
@@ -1949,242 +1960,6 @@ function appendLookupStages(pipeline, lookups, projection, keysetSort) {
|
|
|
1949
1960
|
if (finalProjection) pipeline.push({ $project: finalProjection });
|
|
1950
1961
|
}
|
|
1951
1962
|
//#endregion
|
|
1952
|
-
//#region src/tx-bound.ts
|
|
1953
|
-
/**
|
|
1954
|
-
* Map of session-aware method name → positional index of the `{ session }`
|
|
1955
|
-
* options bag. Keep this list in sync with the method signatures in
|
|
1956
|
-
* `Repository.ts` and the plugin method definitions. Unknown plugin
|
|
1957
|
-
* methods pass through unwrapped (callers use the standalone helper).
|
|
1958
|
-
*/
|
|
1959
|
-
const SESSION_OPTIONS_INDEX = Object.freeze({
|
|
1960
|
-
create: 1,
|
|
1961
|
-
update: 2,
|
|
1962
|
-
delete: 1,
|
|
1963
|
-
getById: 1,
|
|
1964
|
-
getAll: 1,
|
|
1965
|
-
createMany: 1,
|
|
1966
|
-
findAll: 1,
|
|
1967
|
-
getOrCreate: 2,
|
|
1968
|
-
count: 1,
|
|
1969
|
-
exists: 1,
|
|
1970
|
-
getByQuery: 1,
|
|
1971
|
-
getOne: 1,
|
|
1972
|
-
findOneAndUpdate: 2,
|
|
1973
|
-
distinct: 2,
|
|
1974
|
-
aggregate: 1,
|
|
1975
|
-
aggregatePaginate: 0,
|
|
1976
|
-
lookupPopulate: 0,
|
|
1977
|
-
upsert: 2,
|
|
1978
|
-
increment: 3,
|
|
1979
|
-
decrement: 3,
|
|
1980
|
-
multiplyField: 3,
|
|
1981
|
-
setMin: 3,
|
|
1982
|
-
setMax: 3,
|
|
1983
|
-
pushToArray: 3,
|
|
1984
|
-
pullFromArray: 3,
|
|
1985
|
-
addToSet: 3,
|
|
1986
|
-
setField: 3,
|
|
1987
|
-
unsetField: 2,
|
|
1988
|
-
renameField: 3,
|
|
1989
|
-
atomicUpdate: 2,
|
|
1990
|
-
updateMany: 2,
|
|
1991
|
-
deleteMany: 1,
|
|
1992
|
-
bulkWrite: 1,
|
|
1993
|
-
restore: 1,
|
|
1994
|
-
getDeleted: 1,
|
|
1995
|
-
addSubdocument: 3,
|
|
1996
|
-
getSubdocument: 3,
|
|
1997
|
-
updateSubdocument: 4,
|
|
1998
|
-
deleteSubdocument: 3,
|
|
1999
|
-
groupBy: 1,
|
|
2000
|
-
sum: 2,
|
|
2001
|
-
average: 2,
|
|
2002
|
-
min: 2,
|
|
2003
|
-
max: 2
|
|
2004
|
-
});
|
|
2005
|
-
/**
|
|
2006
|
-
* Build a session-threaded proxy over `outer`. The returned object has the
|
|
2007
|
-
* same method signatures, but every CRUD call auto-injects the supplied
|
|
2008
|
-
* session into the options bag. Non-CRUD properties (Model, modelName,
|
|
2009
|
-
* hook API, utility helpers) pass through.
|
|
2010
|
-
*/
|
|
2011
|
-
function createTxBoundRepo(outer, session) {
|
|
2012
|
-
return new Proxy(outer, { get(target, prop, receiver) {
|
|
2013
|
-
if (prop === "withTransaction") return () => {
|
|
2014
|
-
throw new Error("[mongokit] Nested withTransaction is not supported on a tx-bound repository. Reuse the outer `txRepo` directly, or collapse the nesting.");
|
|
2015
|
-
};
|
|
2016
|
-
const value = Reflect.get(target, prop, receiver);
|
|
2017
|
-
if (typeof value !== "function") return value;
|
|
2018
|
-
if (prop === "Model") return value;
|
|
2019
|
-
if (typeof prop === "symbol") return value.bind(target);
|
|
2020
|
-
if (prop.startsWith("_")) return value.bind(target);
|
|
2021
|
-
const optionsIndex = SESSION_OPTIONS_INDEX[prop];
|
|
2022
|
-
if (optionsIndex === void 0) return value.bind(target);
|
|
2023
|
-
return function txBoundMethod(...args) {
|
|
2024
|
-
while (args.length <= optionsIndex) args.push(void 0);
|
|
2025
|
-
const current = args[optionsIndex];
|
|
2026
|
-
if (current === void 0) args[optionsIndex] = { session };
|
|
2027
|
-
else if (typeof current === "object" && current !== null && !Array.isArray(current)) args[optionsIndex] = {
|
|
2028
|
-
...current,
|
|
2029
|
-
session
|
|
2030
|
-
};
|
|
2031
|
-
else return value.apply(target, args);
|
|
2032
|
-
return value.apply(target, args);
|
|
2033
|
-
};
|
|
2034
|
-
} });
|
|
2035
|
-
}
|
|
2036
|
-
//#endregion
|
|
2037
|
-
//#region src/transaction.ts
|
|
2038
|
-
/**
|
|
2039
|
-
* Run a callback inside a MongoDB transaction on the given connection.
|
|
2040
|
-
*
|
|
2041
|
-
* - Starts a session, runs `session.withTransaction(callback)` (which auto-retries
|
|
2042
|
-
* on `TransientTransactionError` and `UnknownTransactionCommitResult`), and
|
|
2043
|
-
* always ends the session in `finally`.
|
|
2044
|
-
* - When `allowFallback` is true and the deployment doesn't support transactions
|
|
2045
|
-
* (e.g. standalone MongoDB in dev), the callback runs once without a
|
|
2046
|
-
* transaction on the same session. `onFallback` is invoked with the original
|
|
2047
|
-
* error so hosts can log the degradation.
|
|
2048
|
-
*
|
|
2049
|
-
* @example
|
|
2050
|
-
* ```ts
|
|
2051
|
-
* import mongoose from 'mongoose';
|
|
2052
|
-
* import { withTransaction } from '@classytic/mongokit';
|
|
2053
|
-
*
|
|
2054
|
-
* await withTransaction(mongoose.connection, async (session) => {
|
|
2055
|
-
* const order = await orderRepo.create(data, { session });
|
|
2056
|
-
* await inventoryRepo.decrement(order.items, { session });
|
|
2057
|
-
* return order;
|
|
2058
|
-
* });
|
|
2059
|
-
* ```
|
|
2060
|
-
*/
|
|
2061
|
-
async function withTransaction(connection, callback, options = {}) {
|
|
2062
|
-
const session = await connection.startSession();
|
|
2063
|
-
try {
|
|
2064
|
-
return await session.withTransaction(() => callback(session), options.transactionOptions);
|
|
2065
|
-
} catch (error) {
|
|
2066
|
-
const err = error;
|
|
2067
|
-
if (options.allowFallback && isTransactionUnsupported(err)) {
|
|
2068
|
-
options.onFallback?.(err);
|
|
2069
|
-
return await callback(session);
|
|
2070
|
-
}
|
|
2071
|
-
throw err;
|
|
2072
|
-
} finally {
|
|
2073
|
-
await session.endSession();
|
|
2074
|
-
}
|
|
2075
|
-
}
|
|
2076
|
-
/**
|
|
2077
|
-
* Multi-repo transactional batch — every repo in `repos` becomes a
|
|
2078
|
-
* session-bound proxy inside the callback. Eliminates the per-call
|
|
2079
|
-
* `{ session }` threading that was repeated across ~20 call sites
|
|
2080
|
-
* (be-prod outbox writes, order placement, transfer source/dest pairs,
|
|
2081
|
-
* payrun saga steps).
|
|
2082
|
-
*
|
|
2083
|
-
* Same retry + fallback semantics as `withTransaction`: the
|
|
2084
|
-
* `transactionOptions` / `allowFallback` / `onFallback` knobs apply.
|
|
2085
|
-
*
|
|
2086
|
-
* Each property of the input `repos` map is rebound via the
|
|
2087
|
-
* `createTxBoundRepo` proxy — every CRUD method on the bound repo
|
|
2088
|
-
* auto-injects `session` into its options bag, including
|
|
2089
|
-
* `claim` / `claimVersion` / `findOneAndUpdate` and plugin-contributed
|
|
2090
|
-
* methods. Non-CRUD properties (Model, modelName, hook engine,
|
|
2091
|
-
* idField) pass through to the underlying repo. Nested
|
|
2092
|
-
* `boundRepo.withTransaction(...)` throws — reuse the outer bound
|
|
2093
|
-
* repos.
|
|
2094
|
-
*
|
|
2095
|
-
* @example Order placement across three repos in one transaction
|
|
2096
|
-
* ```ts
|
|
2097
|
-
* import { batchTransaction } from '@classytic/mongokit';
|
|
2098
|
-
*
|
|
2099
|
-
* const order = await batchTransaction(
|
|
2100
|
-
* mongoose.connection,
|
|
2101
|
-
* { orders: orderRepo, events: eventRepo, inventory: inventoryRepo },
|
|
2102
|
-
* async ({ orders, events, inventory }) => {
|
|
2103
|
-
* const created = await orders.create(orderData); // session auto-injected
|
|
2104
|
-
* await events.create({ type: 'order.placed', orderId: created._id });
|
|
2105
|
-
* await inventory.claim(skuId, { from: 'available', to: 'reserved' });
|
|
2106
|
-
* return created;
|
|
2107
|
-
* },
|
|
2108
|
-
* );
|
|
2109
|
-
* ```
|
|
2110
|
-
*
|
|
2111
|
-
* @example With fallback for standalone-mongo dev environments
|
|
2112
|
-
* ```ts
|
|
2113
|
-
* await batchTransaction(
|
|
2114
|
-
* mongoose.connection,
|
|
2115
|
-
* { orders, events },
|
|
2116
|
-
* async ({ orders, events }) => { ... },
|
|
2117
|
-
* { allowFallback: true, onFallback: (err) => log.warn(err) },
|
|
2118
|
-
* );
|
|
2119
|
-
* ```
|
|
2120
|
-
*
|
|
2121
|
-
* @param connection - Mongoose connection (or anything with
|
|
2122
|
-
* `startSession()`). Single source of session truth — every bound
|
|
2123
|
-
* repo shares this session.
|
|
2124
|
-
* @param repos - Map of repo instances to rebind. Keys become the
|
|
2125
|
-
* property names on the callback's argument.
|
|
2126
|
-
* @param callback - Receives the bound repo map. Return value flows
|
|
2127
|
-
* through to the outer `Promise`.
|
|
2128
|
-
*/
|
|
2129
|
-
async function batchTransaction(connection, repos, callback, options = {}) {
|
|
2130
|
-
return withTransaction(connection, async (session) => {
|
|
2131
|
-
const bound = {};
|
|
2132
|
-
for (const key of Object.keys(repos)) {
|
|
2133
|
-
const repo = repos[key];
|
|
2134
|
-
bound[key] = createTxBoundRepo(repo, session);
|
|
2135
|
-
}
|
|
2136
|
-
return callback(bound);
|
|
2137
|
-
}, options);
|
|
2138
|
-
}
|
|
2139
|
-
/**
|
|
2140
|
-
* Detect whether an error indicates the MongoDB deployment does not support
|
|
2141
|
-
* multi-document transactions (standalone server, older topology, etc.).
|
|
2142
|
-
*
|
|
2143
|
-
* Checks MongoDB error codes first — 263 (standalone) and 20 (unsupported
|
|
2144
|
-
* topology) — with a message-matching fallback for edge cases surfaced by
|
|
2145
|
-
* driver versions that throw before the proper code lands.
|
|
2146
|
-
*
|
|
2147
|
-
* **Driver-version drift:** modern mongoose / mongodb-driver versions hit
|
|
2148
|
-
* the standalone case via the retryable-writes precondition rather than
|
|
2149
|
-
* the transaction precondition (`retryWrites=true` is on by default,
|
|
2150
|
-
* standalone Mongo rejects it before the transaction is even attempted).
|
|
2151
|
-
* So we accept that message as equivalent — the underlying topology
|
|
2152
|
-
* problem is the same, and the fallback semantics are correct either way.
|
|
2153
|
-
*/
|
|
2154
|
-
function isTransactionUnsupported(error) {
|
|
2155
|
-
const code = error.code;
|
|
2156
|
-
if (code === 263 || code === 20) return true;
|
|
2157
|
-
const message = (error.message || "").toLowerCase();
|
|
2158
|
-
return message.includes("transaction numbers are only allowed on a replica set member") || message.includes("transaction is not supported") || message.includes("does not support retryable writes");
|
|
2159
|
-
}
|
|
2160
|
-
/**
|
|
2161
|
-
* PROACTIVELY report whether a connection's deployment supports multi-document
|
|
2162
|
-
* transactions — the read-side companion to {@link isTransactionUnsupported}
|
|
2163
|
-
* (which classifies a FAILED attempt). Lets a caller skip a doomed transaction
|
|
2164
|
-
* attempt on standalone dev Mongo rather than starting one just to catch the
|
|
2165
|
-
* error.
|
|
2166
|
-
*
|
|
2167
|
-
* Delegates to {@link resolveTransactionSupport} so mongokit has exactly ONE
|
|
2168
|
-
* topology reader. That also fixes a false NEGATIVE this function used to
|
|
2169
|
-
* have: it tested `description.type !== 'Single'`, but a single-node replica
|
|
2170
|
-
* set reached with `directConnection` is `Single` with a server type of
|
|
2171
|
-
* `RSPrimary` and runs transactions fine. The shared reader looks at the
|
|
2172
|
-
* SERVER descriptions first.
|
|
2173
|
-
*
|
|
2174
|
-
* **`unknown` is optimistic HERE and pessimistic in `capabilities.ts` — the
|
|
2175
|
-
* difference is deliberate.** This answers "should I bother ATTEMPTING a
|
|
2176
|
-
* transaction?", where guessing yes costs one caught error (`allowFallback`
|
|
2177
|
-
* still recovers). The capability descriptor answers "may this deployment be
|
|
2178
|
-
* trusted with money?", where guessing yes is the bug class this repo keeps
|
|
2179
|
-
* hitting (AGENTS.md FAIL LOUD rule 3). Do not "unify" them.
|
|
2180
|
-
*
|
|
2181
|
-
* Accepts a Mongoose `Connection` (or anything exposing `getClient()`/`client`
|
|
2182
|
-
* with a driver `topology`), so it stays decoupled from mongoose types.
|
|
2183
|
-
*/
|
|
2184
|
-
function supportsTransactions(connection) {
|
|
2185
|
-
return resolveTransactionSupport(connection) !== "no";
|
|
2186
|
-
}
|
|
2187
|
-
//#endregion
|
|
2188
1963
|
//#region src/Repository.ts
|
|
2189
1964
|
/**
|
|
2190
1965
|
* Validates that an update patch is homogeneous — either all top-level
|
|
@@ -2232,6 +2007,41 @@ function assertCasOwnsVersionField(opName, patch, versionField) {
|
|
|
2232
2007
|
throw createError(400, `[${opName}] payload's ${op} targets '${versionField}', but the optimistic-concurrency CAS owns that field — it increments it as part of the same write. ` + (op === "$inc" ? `Only \`$inc: { ${versionField}: 1 }\` is accepted (it matches what the CAS does); any other increment would be silently overridden. ` : `A conflicting ${op} would either fail the update with a Mongo path conflict or be silently overridden. `) + `Remove it; the expected version belongs in \`options.ifVersion\`.`);
|
|
2233
2008
|
}
|
|
2234
2009
|
}
|
|
2010
|
+
/** Filter-bearing ops — those a policy plugin can inject a scope into. */
|
|
2011
|
+
function carriesAFilter(op) {
|
|
2012
|
+
const policyKey = OP_REGISTRY[op].policyKey;
|
|
2013
|
+
return policyKey === "query" || policyKey === "filters";
|
|
2014
|
+
}
|
|
2015
|
+
/**
|
|
2016
|
+
* Shapes the guard can never say anything useful about, whatever the key:
|
|
2017
|
+
* `watch` (a change stream is cluster-wide however it is filtered) and
|
|
2018
|
+
* `getOrCreate` (its filter is the unique key by construction, and a unique
|
|
2019
|
+
* index on a sharded collection must already contain the shard key).
|
|
2020
|
+
*/
|
|
2021
|
+
const DISTRIBUTION_EXEMPT_SHAPES = /* @__PURE__ */ new Set(["watch", "getOrCreate"]);
|
|
2022
|
+
/**
|
|
2023
|
+
* Verbs the distribution guard inspects — every op whose filter the CALLER
|
|
2024
|
+
* assembles and can therefore omit the shard key.
|
|
2025
|
+
*
|
|
2026
|
+
* DERIVED from `OP_REGISTRY`, not hardcoded: a future query-shaped op is
|
|
2027
|
+
* covered by its one registry entry, which is the same rule the strict-query
|
|
2028
|
+
* diagnostic below follows and the redundancy the registry exists to remove.
|
|
2029
|
+
*/
|
|
2030
|
+
const DISTRIBUTION_GUARDED_OPERATIONS = new Set(ALL_OPERATIONS.filter((op) => carriesAFilter(op) && !OP_REGISTRY[op].hasIdContext && !DISTRIBUTION_EXEMPT_SHAPES.has(op)));
|
|
2031
|
+
/**
|
|
2032
|
+
* Id-addressed verbs (`getById`, `update`, `delete`, `claim*`, `restore`) —
|
|
2033
|
+
* guarded ONLY when the distribution key is not the id field.
|
|
2034
|
+
*
|
|
2035
|
+
* The reasoning they were first excluded under — "the `_id` match is routed,
|
|
2036
|
+
* not scattered" — holds only when `_id` IS the shard key. Sharded on anything
|
|
2037
|
+
* else (`organizationId`, the tenant-prefixed design this guard mostly exists
|
|
2038
|
+
* for), a by-id update is a broadcast to every shard; MongoDB before 7.0
|
|
2039
|
+
* rejected `updateOne`/`deleteOne` without the shard key outright. These verbs
|
|
2040
|
+
* all take a filter slot too, which is where tenant scope lands — so a scoped
|
|
2041
|
+
* write satisfies the guard and only a `bypassTenant` one is flagged, exactly
|
|
2042
|
+
* the call that deserves the scrutiny.
|
|
2043
|
+
*/
|
|
2044
|
+
const ID_ADDRESSED_OPERATIONS = new Set(ALL_OPERATIONS.filter((op) => carriesAFilter(op) && OP_REGISTRY[op].hasIdContext && !DISTRIBUTION_EXEMPT_SHAPES.has(op)));
|
|
2235
2045
|
/** Mongo `operationType` → portable `ChangeEvent.operation`. */
|
|
2236
2046
|
const CHANGE_OPERATION_MAP = {
|
|
2237
2047
|
insert: "create",
|
|
@@ -2269,6 +2079,24 @@ function changeEventTimestamp(change) {
|
|
|
2269
2079
|
* OBSERVABILITY=300, DEFAULT=500) and the two refs are reference-equal.
|
|
2270
2080
|
*/
|
|
2271
2081
|
const HOOK_PRIORITY$1 = HOOK_PRIORITY;
|
|
2082
|
+
/**
|
|
2083
|
+
* `capped` needs an envelope that can say "this total is a FLOOR".
|
|
2084
|
+
*
|
|
2085
|
+
* `paginate()` and `aggregatePaginate()` carry `totalIsLowerBound` through
|
|
2086
|
+
* mongokit's `TExtra` slot. `lookupPopulate` and the aggregate-IR offset path
|
|
2087
|
+
* return repo-core's CORE envelope, which has no such slot — so a bounded count
|
|
2088
|
+
* there would be indistinguishable from a real one, and a caller would render a
|
|
2089
|
+
* ceiling as a total. That is the failure the strategy exists to prevent.
|
|
2090
|
+
*
|
|
2091
|
+
* So they count exactly, and SAY they did. A deployment-wide `capped` policy
|
|
2092
|
+
* keeps working (an exact count is slower, never wrong); the log line is what
|
|
2093
|
+
* makes the one path that ignored the policy findable instead of mysterious.
|
|
2094
|
+
*/
|
|
2095
|
+
function downgradeCappedCount(strategy, where) {
|
|
2096
|
+
if (strategy !== "capped") return strategy;
|
|
2097
|
+
warn(`[mongokit] countStrategy 'capped' is not supported by ${where} — counting exactly. Its result envelope cannot carry \`totalIsLowerBound\`, and an unflagged ceiling reads as a total. Use getAll()/aggregatePaginate() where a bounded count matters.`);
|
|
2098
|
+
return "exact";
|
|
2099
|
+
}
|
|
2272
2100
|
var Repository = class extends RepositoryBase {
|
|
2273
2101
|
/**
|
|
2274
2102
|
* Runtime capability descriptor required by `StandardRepo<TDoc>`
|
|
@@ -2320,6 +2148,18 @@ var Repository = class extends RepositoryBase {
|
|
|
2320
2148
|
* the `Middleware<TDoc>` type for the protocol.
|
|
2321
2149
|
*/
|
|
2322
2150
|
_middlewares = [];
|
|
2151
|
+
/**
|
|
2152
|
+
* Shard-key guard built once from `options.distribution`; `undefined`
|
|
2153
|
+
* when not configured so the per-op check is a single falsy test.
|
|
2154
|
+
*/
|
|
2155
|
+
_checkDistribution;
|
|
2156
|
+
/**
|
|
2157
|
+
* Whether id-addressed verbs are guarded too — true unless the
|
|
2158
|
+
* distribution key IS the id field, where an `_id` match is genuinely
|
|
2159
|
+
* routed and guarding would flag every by-id call. See
|
|
2160
|
+
* {@link ID_ADDRESSED_OPERATIONS}.
|
|
2161
|
+
*/
|
|
2162
|
+
_guardIdAddressed = false;
|
|
2323
2163
|
constructor(Model, plugins = [], paginationConfig = {}, options = {}) {
|
|
2324
2164
|
super({
|
|
2325
2165
|
name: Model.modelName,
|
|
@@ -2341,6 +2181,10 @@ var Repository = class extends RepositoryBase {
|
|
|
2341
2181
|
this.searchMode = options.searchMode ?? "text";
|
|
2342
2182
|
this.searchFields = options.searchFields;
|
|
2343
2183
|
this._wireStrictQueryStripDiagnostic(options.warnOnStrictQueryStrip === true);
|
|
2184
|
+
this._checkDistribution = options.distribution ? createDistributionGuard(options.distribution, options.distribution.onMiss ?? ((info) => {
|
|
2185
|
+
warn(`[mongokit] Repository "${this.model}": ${info.operation} filter omits the distribution key "${info.key}" — this fans out across every shard. Include the key, list the op in distribution.exemptOperations, or set onMissingKey: 'off'.`);
|
|
2186
|
+
})) : void 0;
|
|
2187
|
+
this._guardIdAddressed = options.distribution !== void 0 && options.distribution.key !== this.idField;
|
|
2344
2188
|
if (this.searchMode === "regex" && (!this.searchFields || this.searchFields.length === 0)) warn(`[mongokit] Repository "${this.model}" configured with searchMode: 'regex' but no searchFields provided. getAll({ search }) will throw until searchFields is set.`);
|
|
2345
2189
|
for (const plugin of plugins) this.use(plugin);
|
|
2346
2190
|
if (options.requirePlugins && options.requirePlugins.length > 0) {
|
|
@@ -2872,8 +2716,12 @@ var Repository = class extends RepositoryBase {
|
|
|
2872
2716
|
* repo without a filter to observe deletes.
|
|
2873
2717
|
* - `options.signal` ends the iterator (closes the stream). A
|
|
2874
2718
|
* pre-aborted signal rejects at the op boundary like every other op.
|
|
2875
|
-
* - `
|
|
2876
|
-
*
|
|
2719
|
+
* - Every yielded event carries `resumeToken` (the change document's
|
|
2720
|
+
* `_id`). Persist the last one you finished processing and pass it
|
|
2721
|
+
* back as `options.resumeAfter` — or `options.startAfter` to also
|
|
2722
|
+
* step past an invalidate event — for at-least-once consumption
|
|
2723
|
+
* across restarts. Setting both is rejected up front (Mongo forbids
|
|
2724
|
+
* the combination; the driver error is far less legible).
|
|
2877
2725
|
*
|
|
2878
2726
|
* Context keys for policy plugins (`organizationId`, `bypassTenant`,
|
|
2879
2727
|
* `includeDeleted`, ...) ride the options bag at runtime — same as every
|
|
@@ -2890,6 +2738,7 @@ var Repository = class extends RepositoryBase {
|
|
|
2890
2738
|
* ```
|
|
2891
2739
|
*/
|
|
2892
2740
|
async *watch(filter, options = {}) {
|
|
2741
|
+
if (options.resumeAfter !== void 0 && options.startAfter !== void 0) throw createError(400, "watch() accepts either `resumeAfter` or `startAfter`, not both — they are alternative resume positions for the same stream.");
|
|
2893
2742
|
const context = await this._buildContext("watch", {
|
|
2894
2743
|
query: filter ?? {},
|
|
2895
2744
|
...options
|
|
@@ -2904,9 +2753,60 @@ var Repository = class extends RepositoryBase {
|
|
|
2904
2753
|
if (Object.keys(compiled).length > 0) pipeline.push({ $match: prefixMatchPaths(compiled, "fullDocument") });
|
|
2905
2754
|
const stream = this.Model.watch(pipeline, {
|
|
2906
2755
|
fullDocument: "updateLookup",
|
|
2907
|
-
...options.resumeAfter !== void 0 ? { resumeAfter: options.resumeAfter } : {}
|
|
2756
|
+
...options.resumeAfter !== void 0 ? { resumeAfter: options.resumeAfter } : {},
|
|
2757
|
+
...options.startAfter !== void 0 ? { startAfter: options.startAfter } : {}
|
|
2908
2758
|
});
|
|
2759
|
+
/**
|
|
2760
|
+
* A change feed has NO backpressure — the server pushes, and this bridge
|
|
2761
|
+
* buffers whatever the consumer has not reached yet. Unbounded, a consumer
|
|
2762
|
+
* merely slower than the write rate becomes unbounded memory growth whose
|
|
2763
|
+
* symptom (an OOM) appears nowhere near its cause.
|
|
2764
|
+
*
|
|
2765
|
+
* `head` rather than `queue.shift()`: shift is O(n) on a real array, so a
|
|
2766
|
+
* backlog made draining quadratic exactly when the process was already
|
|
2767
|
+
* struggling. The array is compacted once the read head passes halfway, so
|
|
2768
|
+
* the retained slots stay bounded by the live depth rather than by
|
|
2769
|
+
* everything ever enqueued.
|
|
2770
|
+
*/
|
|
2771
|
+
/**
|
|
2772
|
+
* An unusable bound must not read as "no bound".
|
|
2773
|
+
*
|
|
2774
|
+
* `maxBuffered > 0` is the guard below, and a negative, a fraction,
|
|
2775
|
+
* `Infinity` and `NaN` all slip past it into UNBOUNDED buffering — the
|
|
2776
|
+
* failure this option exists to prevent, silently, in a deployment that
|
|
2777
|
+
* believed it had configured a ceiling. Same shape as the `countLimit`
|
|
2778
|
+
* ceiling, and refused the same way.
|
|
2779
|
+
*
|
|
2780
|
+
* `0` stays legal because it is the documented, explicit escape hatch;
|
|
2781
|
+
* every other non-positive-integer is a mistake.
|
|
2782
|
+
*/
|
|
2783
|
+
const maxBuffered = options.maxBufferedEvents ?? 1024;
|
|
2784
|
+
if (maxBuffered !== 0 && !(Number.isInteger(maxBuffered) && maxBuffered > 0)) throw createError(400, `watch(): maxBufferedEvents must be a positive integer, or 0 to disable the bound — got ${String(maxBuffered)}. A negative, fractional, infinite or NaN value would silently buffer without limit, which is the failure the option exists to prevent.`);
|
|
2909
2785
|
const queue = [];
|
|
2786
|
+
let head = 0;
|
|
2787
|
+
let overflowed = false;
|
|
2788
|
+
/**
|
|
2789
|
+
* Delivered is not processed.
|
|
2790
|
+
*
|
|
2791
|
+
* `lastDelivered` is set before the `yield`; `lastAcknowledged` only when
|
|
2792
|
+
* execution RESUMES after it, which happens when the consumer comes back
|
|
2793
|
+
* for the next value — the one observable proof it finished with the
|
|
2794
|
+
* previous one.
|
|
2795
|
+
*
|
|
2796
|
+
* The distinction is the whole difference between at-least-once and
|
|
2797
|
+
* silent loss. Resuming after a merely DELIVERED token skips an event the
|
|
2798
|
+
* consumer may have been halfway through when the stream ended: it was
|
|
2799
|
+
* handed over, never processed, and never seen again. Resuming after the
|
|
2800
|
+
* ACKNOWLEDGED one redelivers at most the in-flight event, which an
|
|
2801
|
+
* idempotent consumer is already built to absorb.
|
|
2802
|
+
*
|
|
2803
|
+
* The asymmetry is the point: redelivering costs a duplicate, skipping
|
|
2804
|
+
* costs a lost write. For money, inventory and outbox consumers those are
|
|
2805
|
+
* not comparable.
|
|
2806
|
+
*/
|
|
2807
|
+
let lastDeliveredToken;
|
|
2808
|
+
let lastAcknowledgedToken;
|
|
2809
|
+
const depth = () => queue.length - head;
|
|
2910
2810
|
let streamError = null;
|
|
2911
2811
|
let ended = false;
|
|
2912
2812
|
let notify = null;
|
|
@@ -2916,6 +2816,26 @@ var Repository = class extends RepositoryBase {
|
|
|
2916
2816
|
resolve?.();
|
|
2917
2817
|
};
|
|
2918
2818
|
const onChange = (change) => {
|
|
2819
|
+
if (overflowed) return;
|
|
2820
|
+
if (maxBuffered > 0 && depth() >= maxBuffered) {
|
|
2821
|
+
overflowed = true;
|
|
2822
|
+
streamError = createError(503, `watch(): consumer fell more than ${maxBuffered} events behind and the buffer is full. Events are never dropped, so the stream ends here — resume with \`startAfter\` from this error's \`resumeToken\`, which is the last event the consumer ACKNOWLEDGED (not the last delivered: resuming past an event still being processed would lose it). Raise \`maxBufferedEvents\` for burst tolerance, or move consumption off the iterator onto a durable queue.`);
|
|
2823
|
+
/**
|
|
2824
|
+
* `resumeToken` is the ACKNOWLEDGED token, never the delivered one:
|
|
2825
|
+
* restarting after an event the consumer had not finished would drop
|
|
2826
|
+
* it. `deliveredToken` rides along for diagnostics only — a supervisor
|
|
2827
|
+
* that resumes from it reintroduces the loss this separation prevents.
|
|
2828
|
+
*
|
|
2829
|
+
* Both may be `undefined` when overflow arrives before the consumer
|
|
2830
|
+
* completed anything; the caller then restarts from whatever position
|
|
2831
|
+
* it opened the stream with, not from here.
|
|
2832
|
+
*/
|
|
2833
|
+
const overflowError = streamError;
|
|
2834
|
+
overflowError.resumeToken = lastAcknowledgedToken;
|
|
2835
|
+
overflowError.deliveredToken = lastDeliveredToken;
|
|
2836
|
+
wake();
|
|
2837
|
+
return;
|
|
2838
|
+
}
|
|
2919
2839
|
queue.push(change);
|
|
2920
2840
|
wake();
|
|
2921
2841
|
};
|
|
@@ -2948,16 +2868,23 @@ var Repository = class extends RepositoryBase {
|
|
|
2948
2868
|
}
|
|
2949
2869
|
while (true) {
|
|
2950
2870
|
if (signal?.aborted) return;
|
|
2951
|
-
const change = queue.
|
|
2871
|
+
const change = head < queue.length ? queue[head++] : void 0;
|
|
2872
|
+
if (head > 32 && head * 2 >= queue.length) {
|
|
2873
|
+
queue.splice(0, head);
|
|
2874
|
+
head = 0;
|
|
2875
|
+
}
|
|
2952
2876
|
if (change) {
|
|
2953
2877
|
const operation = CHANGE_OPERATION_MAP[change.operationType];
|
|
2954
2878
|
if (!operation) continue;
|
|
2879
|
+
lastDeliveredToken = change._id;
|
|
2955
2880
|
yield {
|
|
2956
2881
|
operation,
|
|
2957
2882
|
id: change.documentKey?._id,
|
|
2958
2883
|
...change.fullDocument !== void 0 ? { doc: change.fullDocument } : {},
|
|
2959
|
-
timestamp: changeEventTimestamp(change)
|
|
2884
|
+
timestamp: changeEventTimestamp(change),
|
|
2885
|
+
...change._id !== void 0 ? { resumeToken: change._id } : {}
|
|
2960
2886
|
};
|
|
2887
|
+
lastAcknowledgedToken = change._id;
|
|
2961
2888
|
continue;
|
|
2962
2889
|
}
|
|
2963
2890
|
if (streamError) {
|
|
@@ -2977,29 +2904,6 @@ var Repository = class extends RepositoryBase {
|
|
|
2977
2904
|
await closeStream();
|
|
2978
2905
|
}
|
|
2979
2906
|
}
|
|
2980
|
-
/**
|
|
2981
|
-
* Unified pagination - auto-detects offset vs keyset based on params
|
|
2982
|
-
*
|
|
2983
|
-
* Auto-detection logic:
|
|
2984
|
-
* - If params has 'cursor' or 'after' → uses keyset pagination (stream)
|
|
2985
|
-
* - If params has 'pagination' or 'page' → uses offset pagination (paginate)
|
|
2986
|
-
* - Else → defaults to offset pagination with page=1
|
|
2987
|
-
*
|
|
2988
|
-
* @example
|
|
2989
|
-
* // Offset pagination (page-based)
|
|
2990
|
-
* await repo.getAll({ page: 1, limit: 50, filters: { status: 'active' } });
|
|
2991
|
-
* await repo.getAll({ pagination: { page: 2, limit: 20 } });
|
|
2992
|
-
*
|
|
2993
|
-
* // Keyset pagination (cursor-based)
|
|
2994
|
-
* await repo.getAll({ cursor: 'eyJ2Ij...', limit: 50 });
|
|
2995
|
-
* await repo.getAll({ after: 'eyJ2Ij...', sort: { createdAt: -1 } });
|
|
2996
|
-
*
|
|
2997
|
-
* // Simple query (defaults to page 1)
|
|
2998
|
-
* await repo.getAll({ filters: { status: 'active' } });
|
|
2999
|
-
*
|
|
3000
|
-
* // Skip cache for fresh data
|
|
3001
|
-
* await repo.getAll({ filters: { status: 'active' } }, { skipCache: true });
|
|
3002
|
-
*/
|
|
3003
2907
|
async getAll(params = {}, options = {}) {
|
|
3004
2908
|
const normalizedParams = {
|
|
3005
2909
|
...params,
|
|
@@ -3034,8 +2938,9 @@ var Repository = class extends RepositoryBase {
|
|
|
3034
2938
|
const limit = context.limit ?? params.limit ?? params.pagination?.limit ?? this._pagination.config.defaultLimit;
|
|
3035
2939
|
const page = context.page ?? params.pagination?.page ?? params.page;
|
|
3036
2940
|
const after = context.after ?? params.cursor ?? params.after;
|
|
2941
|
+
const before = context.before ?? params.before;
|
|
3037
2942
|
const mode = context.mode ?? params.mode;
|
|
3038
|
-
const useKeyset = this._detectPaginationMode(mode, page, after, sort, context, params);
|
|
2943
|
+
const useKeyset = this._detectPaginationMode(mode, page, after ?? before, sort, context, params);
|
|
3039
2944
|
const query = this._buildSearchQuery(filters, search);
|
|
3040
2945
|
const populateSpec = options.populateOptions || params.populateOptions || context.populate || options.populate;
|
|
3041
2946
|
const paginationOptions = {
|
|
@@ -3048,6 +2953,7 @@ var Repository = class extends RepositoryBase {
|
|
|
3048
2953
|
session: options.session,
|
|
3049
2954
|
hint: context.hint ?? params.hint,
|
|
3050
2955
|
maxTimeMS: context.maxTimeMS ?? params.maxTimeMS,
|
|
2956
|
+
countLimit: context.countLimit ?? params.countLimit,
|
|
3051
2957
|
readPreference: context.readPreference ?? options.readPreference ?? params.readPreference,
|
|
3052
2958
|
collation: context.collation ?? params.collation
|
|
3053
2959
|
};
|
|
@@ -3064,7 +2970,8 @@ var Repository = class extends RepositoryBase {
|
|
|
3064
2970
|
session: options.session,
|
|
3065
2971
|
readPreference: paginationOptions.readPreference,
|
|
3066
2972
|
collation: paginationOptions.collation,
|
|
3067
|
-
countStrategy: context.countStrategy ?? params.countStrategy
|
|
2973
|
+
countStrategy: context.countStrategy ?? params.countStrategy,
|
|
2974
|
+
countLimit: context.countLimit ?? params.countLimit
|
|
3068
2975
|
});
|
|
3069
2976
|
});
|
|
3070
2977
|
return this._runOp("getAll", context, async () => {
|
|
@@ -3072,7 +2979,8 @@ var Repository = class extends RepositoryBase {
|
|
|
3072
2979
|
if (useKeyset) result = await this._pagination.stream({
|
|
3073
2980
|
...paginationOptions,
|
|
3074
2981
|
sort: paginationOptions.sort,
|
|
3075
|
-
after
|
|
2982
|
+
after,
|
|
2983
|
+
before
|
|
3076
2984
|
});
|
|
3077
2985
|
else {
|
|
3078
2986
|
let countFilters;
|
|
@@ -3563,6 +3471,11 @@ var Repository = class extends RepositoryBase {
|
|
|
3563
3471
|
[effectiveIdField]: id,
|
|
3564
3472
|
[stateField]: fromSpec
|
|
3565
3473
|
};
|
|
3474
|
+
const seeded = options.target;
|
|
3475
|
+
if (seeded !== void 0) {
|
|
3476
|
+
if (String(seeded[effectiveIdField]) !== String(id)) throw new Error(`[claim] options.target is ${effectiveIdField}=${String(seeded[effectiveIdField])}, not the claimed ${String(id)}`);
|
|
3477
|
+
for (const key of [this.versionField, "updatedAt"]) if (seeded[key] !== void 0 && !(key in filter)) filter[key] = seeded[key];
|
|
3478
|
+
}
|
|
3566
3479
|
assertNoMixedPatchShape("claim", patch);
|
|
3567
3480
|
const patchKeys = Object.keys(patch);
|
|
3568
3481
|
const operatorPatchKeys = patchKeys.filter((k) => k.startsWith("$"));
|
|
@@ -4260,7 +4173,7 @@ var Repository = class extends RepositoryBase {
|
|
|
4260
4173
|
async _executeAggregatePaginate(finalReq, useKeyset, limit, session) {
|
|
4261
4174
|
if (useKeyset) {
|
|
4262
4175
|
if (!finalReq.sort || Object.keys(finalReq.sort).length === 0) throw new Error("mongokit/aggregatePaginate: keyset pagination requires `sort` — the cursor anchors on the sort-key tuple");
|
|
4263
|
-
const cursor = finalReq.after ? decodeAggCursor$1(finalReq.after) : void 0;
|
|
4176
|
+
const cursor = finalReq.after ? decodeAggCursor$1(finalReq.after, this._pagination.config.cursorSecret) : void 0;
|
|
4264
4177
|
const keysetMatch = cursor ? buildKeysetPredicate(finalReq.sort, cursor) : void 0;
|
|
4265
4178
|
const { pipeline, prePaginationIndex } = buildAggPipeline(finalReq, this.Model.schema);
|
|
4266
4179
|
const headStages = pipeline.slice(0, prePaginationIndex);
|
|
@@ -4279,11 +4192,11 @@ var Repository = class extends RepositoryBase {
|
|
|
4279
4192
|
data,
|
|
4280
4193
|
limit,
|
|
4281
4194
|
hasMore,
|
|
4282
|
-
next: hasMore && data.length > 0 ? encodeAggCursor(data[data.length - 1], finalReq.sort) : null
|
|
4195
|
+
next: hasMore && data.length > 0 ? encodeAggCursor$1(data[data.length - 1], finalReq.sort, this._pagination.config.cursorSecret) : null
|
|
4283
4196
|
};
|
|
4284
4197
|
}
|
|
4285
4198
|
const page = Math.max(1, finalReq.page ?? 1);
|
|
4286
|
-
const countStrategy = finalReq.countStrategy ?? "
|
|
4199
|
+
const countStrategy = downgradeCappedCount(finalReq.countStrategy ?? this._pagination.config.defaultCountStrategy, "aggregatePaginateIr");
|
|
4287
4200
|
const offset = (page - 1) * limit;
|
|
4288
4201
|
if (countStrategy === "none") {
|
|
4289
4202
|
const peek = await executeAgg(this.Model, {
|
|
@@ -4412,18 +4325,18 @@ var Repository = class extends RepositoryBase {
|
|
|
4412
4325
|
const after = context.after ?? options.after;
|
|
4413
4326
|
const pageFromContext = context.page ?? options.page;
|
|
4414
4327
|
const isKeyset = !!after || !pageFromContext && !!sort;
|
|
4415
|
-
const countStrategy = context.countStrategy ?? options.countStrategy ?? "
|
|
4328
|
+
const countStrategy = downgradeCappedCount(context.countStrategy ?? options.countStrategy ?? this._pagination.config.defaultCountStrategy, "lookupPopulate");
|
|
4416
4329
|
const projection = buildLookupProjection(context.select ?? options.select, lookups);
|
|
4417
4330
|
const appendLookupStages$2 = (pipeline) => appendLookupStages(pipeline, lookups, projection, isKeyset && sort ? this._parseSort(sort) : void 0);
|
|
4418
4331
|
if (isKeyset && sort) {
|
|
4419
4332
|
const parsedSort = this._parseSort(sort);
|
|
4420
|
-
const { validateKeysetSort } = await import("./PaginationEngine-
|
|
4421
|
-
const { encodeCursor, resolveCursorFilter } = await import("./PaginationEngine-
|
|
4422
|
-
const { getPrimaryField } = await import("./PaginationEngine-
|
|
4333
|
+
const { validateKeysetSort } = await import("./PaginationEngine-Dvl8jcRY.mjs").then((n) => n.n);
|
|
4334
|
+
const { encodeCursor, resolveCursorFilter } = await import("./PaginationEngine-Dvl8jcRY.mjs").then((n) => n.r);
|
|
4335
|
+
const { getPrimaryField } = await import("./PaginationEngine-Dvl8jcRY.mjs").then((n) => n.n);
|
|
4423
4336
|
const normalizedSort = validateKeysetSort(parsedSort, this._pagination.config.strictKeysetSortFields);
|
|
4424
4337
|
const cursorVersion = this._pagination.config.cursorVersion ?? 1;
|
|
4425
4338
|
const minCursorVersion = this._pagination.config.minCursorVersion ?? 1;
|
|
4426
|
-
const matchFilters = after ? resolveCursorFilter(after, normalizedSort, cursorVersion, { ...filters || {} }, minCursorVersion) : { ...filters || {} };
|
|
4339
|
+
const matchFilters = after ? resolveCursorFilter(after, normalizedSort, cursorVersion, { ...filters || {} }, minCursorVersion, normalizedSort, this._pagination.config.cursorSecret) : { ...filters || {} };
|
|
4427
4340
|
if (projection) {
|
|
4428
4341
|
if (Object.values(projection).some((v) => v === 1)) {
|
|
4429
4342
|
for (const sortField of Object.keys(normalizedSort)) if (!(sortField in projection)) projection[sortField] = 1;
|
|
@@ -4446,7 +4359,7 @@ var Repository = class extends RepositoryBase {
|
|
|
4446
4359
|
data,
|
|
4447
4360
|
limit,
|
|
4448
4361
|
hasMore,
|
|
4449
|
-
next: hasMore && data.length > 0 ? encodeCursor(data[data.length - 1], primaryField, normalizedSort, cursorVersion) : null
|
|
4362
|
+
next: hasMore && data.length > 0 ? encodeCursor(data[data.length - 1], primaryField, normalizedSort, cursorVersion, this._pagination.config.cursorSecret) : null
|
|
4450
4363
|
};
|
|
4451
4364
|
await this._emitHook("after:lookupPopulate", {
|
|
4452
4365
|
context,
|
|
@@ -4646,7 +4559,82 @@ var Repository = class extends RepositoryBase {
|
|
|
4646
4559
|
async _buildContext(operation, options) {
|
|
4647
4560
|
throwIfAborted(options.signal);
|
|
4648
4561
|
const normalized = this._normalizeFilterSlots(options);
|
|
4649
|
-
|
|
4562
|
+
const base = await super._buildContext(operation, {
|
|
4563
|
+
...normalized,
|
|
4564
|
+
...this._sharedReadSeams(operation, normalized)
|
|
4565
|
+
});
|
|
4566
|
+
this._guardDistribution(operation, base);
|
|
4567
|
+
return base;
|
|
4568
|
+
}
|
|
4569
|
+
/**
|
|
4570
|
+
* `memo` and (for an id-addressed op) `loadTarget` — attached before the before-hooks
|
|
4571
|
+
* run so every hook of one operation shares one read. A caller that has just read the
|
|
4572
|
+
* document in the same session passes it as `target`, and nothing is read at all.
|
|
4573
|
+
*/
|
|
4574
|
+
_sharedReadSeams(operation, options) {
|
|
4575
|
+
const store = /* @__PURE__ */ new Map();
|
|
4576
|
+
const memo = (key, load) => {
|
|
4577
|
+
let pending = store.get(key);
|
|
4578
|
+
if (!pending) {
|
|
4579
|
+
pending = Promise.resolve().then(load);
|
|
4580
|
+
store.set(key, pending);
|
|
4581
|
+
pending.catch(() => store.delete(key));
|
|
4582
|
+
}
|
|
4583
|
+
return pending;
|
|
4584
|
+
};
|
|
4585
|
+
const id = options.id;
|
|
4586
|
+
if (!OP_REGISTRY[operation]?.hasIdContext || id === void 0) return { memo };
|
|
4587
|
+
const seeded = options.target;
|
|
4588
|
+
const idField = options.idField ?? this.idField;
|
|
4589
|
+
const session = options.session;
|
|
4590
|
+
const supplied = options.query ?? {};
|
|
4591
|
+
const Model = this.Model;
|
|
4592
|
+
function loadTarget() {
|
|
4593
|
+
if (!this || typeof this !== "object") throw new Error("context.loadTarget() must be called as a method (ctx.loadTarget()) — it reads the operation tenant scope");
|
|
4594
|
+
const query = this.query ?? {};
|
|
4595
|
+
const policyScope = Object.fromEntries(Object.entries(query).filter(([k]) => !(k in supplied)));
|
|
4596
|
+
return memo("mongokit:target", async () => seeded !== void 0 ? seeded : await Model.findOne({
|
|
4597
|
+
...policyScope,
|
|
4598
|
+
[idField]: id
|
|
4599
|
+
}, null, { session }).lean());
|
|
4600
|
+
}
|
|
4601
|
+
return {
|
|
4602
|
+
memo,
|
|
4603
|
+
loadTarget
|
|
4604
|
+
};
|
|
4605
|
+
}
|
|
4606
|
+
/**
|
|
4607
|
+
* Feed the resolved filter of a guarded verb to the distribution guard.
|
|
4608
|
+
* The slot follows `OP_REGISTRY[op].policyKey` (`query` vs `filters`);
|
|
4609
|
+
* the portable aggregate verbs ALSO carry the caller's filter on
|
|
4610
|
+
* `aggRequest.filter` (merged into the pipeline later by
|
|
4611
|
+
* `_injectPolicyScopeIntoAgg`), and the kit-native pipeline verbs may
|
|
4612
|
+
* carry it as leading `$match` stages — both are folded in so a key the
|
|
4613
|
+
* caller did supply is never reported missing.
|
|
4614
|
+
*/
|
|
4615
|
+
_guardDistribution(operation, context) {
|
|
4616
|
+
if (!this._checkDistribution) return;
|
|
4617
|
+
if (!(DISTRIBUTION_GUARDED_OPERATIONS.has(operation) || this._guardIdAddressed && ID_ADDRESSED_OPERATIONS.has(operation))) return;
|
|
4618
|
+
const policyKey = OP_REGISTRY[operation]?.policyKey;
|
|
4619
|
+
const parts = [];
|
|
4620
|
+
const push = (candidate) => {
|
|
4621
|
+
if (!candidate || typeof candidate !== "object") return;
|
|
4622
|
+
const compiled = compileFilterToMongo(candidate);
|
|
4623
|
+
if (Object.keys(compiled).length > 0) parts.push(compiled);
|
|
4624
|
+
};
|
|
4625
|
+
push(policyKey === "filters" ? context.filters : context.query);
|
|
4626
|
+
push(context.aggRequest?.filter);
|
|
4627
|
+
const pipeline = context.pipeline;
|
|
4628
|
+
if (Array.isArray(pipeline)) for (const stage of pipeline) {
|
|
4629
|
+
if (!stage || typeof stage !== "object") break;
|
|
4630
|
+
if ("$geoNear" in stage) {
|
|
4631
|
+
push(stage.$geoNear?.query);
|
|
4632
|
+
continue;
|
|
4633
|
+
}
|
|
4634
|
+
if (!("$match" in stage)) break;
|
|
4635
|
+
push(stage.$match);
|
|
4636
|
+
}
|
|
4637
|
+
this._checkDistribution(operation, parts.length === 0 ? void 0 : parts.length === 1 ? parts[0] : { $and: parts });
|
|
4650
4638
|
}
|
|
4651
4639
|
/**
|
|
4652
4640
|
* Walk the well-known filter-carrying keys (`query`, `filters`,
|
|
@@ -4683,10 +4671,17 @@ var Repository = class extends RepositoryBase {
|
|
|
4683
4671
|
}
|
|
4684
4672
|
/**
|
|
4685
4673
|
* Detect whether to use keyset (cursor) or offset (page) pagination.
|
|
4674
|
+
* Precedence: explicit `mode` → `page` (offset) → `after` (keyset) →
|
|
4675
|
+
* `PaginationConfig.defaultMode` → the sort heuristic (a non-default
|
|
4676
|
+
* sort with no page means the caller wants a cursor).
|
|
4686
4677
|
*/
|
|
4687
4678
|
_detectPaginationMode(mode, page, after, sort, context, params) {
|
|
4688
4679
|
if (mode) return mode === "keyset";
|
|
4689
|
-
|
|
4680
|
+
if (page) return false;
|
|
4681
|
+
if (after) return true;
|
|
4682
|
+
const defaultMode = this._pagination.config.defaultMode;
|
|
4683
|
+
if (defaultMode) return defaultMode === "keyset";
|
|
4684
|
+
return sort !== "-createdAt" && !!(context.sort ?? params.sort);
|
|
4690
4685
|
}
|
|
4691
4686
|
/**
|
|
4692
4687
|
* Build the MongoDB query filter with search merged in. Handles text
|
|
@@ -4816,7 +4811,29 @@ var Repository = class extends RepositoryBase {
|
|
|
4816
4811
|
* `errorLabels` exactly as we received it. The caller still sees a
|
|
4817
4812
|
* thrown error — only the wrapping layer is skipped.
|
|
4818
4813
|
*/
|
|
4814
|
+
/**
|
|
4815
|
+
* Translate a driver error into the repository's error contract, WITHOUT
|
|
4816
|
+
* losing structured diagnostics an action attached to it.
|
|
4817
|
+
*
|
|
4818
|
+
* The translation replaces the object — an E11000 becomes a 409 — so any
|
|
4819
|
+
* property a caller was meant to read went with it. `createMany` attaches a
|
|
4820
|
+
* `partial` report naming which documents landed, and it arrived nowhere:
|
|
4821
|
+
* the report existed, the test for it failed, and the only evidence was the
|
|
4822
|
+
* absence.
|
|
4823
|
+
*
|
|
4824
|
+
* Carried explicitly rather than by copying every own-property, because the
|
|
4825
|
+
* wrap is deliberate: the point is to hide driver internals, and a blanket
|
|
4826
|
+
* copy would put them back.
|
|
4827
|
+
*/
|
|
4819
4828
|
_handleError(error) {
|
|
4829
|
+
const translated = this._translateError(error);
|
|
4830
|
+
if (translated !== error) {
|
|
4831
|
+
const partial = error.partial;
|
|
4832
|
+
if (partial !== void 0) translated.partial = partial;
|
|
4833
|
+
}
|
|
4834
|
+
return translated;
|
|
4835
|
+
}
|
|
4836
|
+
_translateError(error) {
|
|
4820
4837
|
const labels = error.errorLabels;
|
|
4821
4838
|
const hasLabel = error.hasErrorLabel;
|
|
4822
4839
|
if (Array.isArray(labels) && (labels.includes("TransientTransactionError") || labels.includes("UnknownTransactionCommitResult")) || typeof hasLabel === "function" && (hasLabel.call(error, "TransientTransactionError") || hasLabel.call(error, "UnknownTransactionCommitResult"))) return error;
|
|
@@ -4833,4 +4850,4 @@ var Repository = class extends RepositoryBase {
|
|
|
4833
4850
|
}
|
|
4834
4851
|
};
|
|
4835
4852
|
//#endregion
|
|
4836
|
-
export {
|
|
4853
|
+
export { MUTATING_OPERATIONS as a, operationsByPolicyKey as c, ALL_OPERATIONS as i, createMongoPurgePort as l, Repository as n, OP_REGISTRY as o, AggregationBuilder as r, READ_OPERATIONS as s, HOOK_PRIORITY$1 as t };
|