@classytic/mongokit 3.39.0 → 3.40.1
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/README.md +71 -0
- package/dist/{PaginationEngine-D-P19Y9m.d.mts → PaginationEngine-CRHyuUtw.d.mts} +17 -3
- package/dist/{PaginationEngine-DQ7beTCR.mjs → PaginationEngine-CfT2Cxkj.mjs} +320 -145
- package/dist/{Repository-D7fDpyFx.mjs → Repository-CYhSC9ut.mjs} +844 -794
- package/dist/{Repository-IxI983_m.d.mts → Repository-pnLw1rG5.d.mts} +113 -47
- package/dist/actions/index.d.mts +1 -1
- package/dist/actions/index.mjs +1 -1
- package/dist/adapter/index.mjs +1 -1
- package/dist/{adapter-BrVfPFSW.mjs → adapter-CkMoMF83.mjs} +1 -1
- package/dist/ai/index.d.mts +1 -1
- package/dist/better-auth/index.mjs +2 -2
- package/dist/{create-repository-CRXjDa3N.d.mts → create-repository-BUx9vfG9.d.mts} +5 -5
- package/dist/{create-repository-DOe9E_5F.mjs → create-repository-BYyWzLbt.mjs} +2 -2
- package/dist/idempotency/index.d.mts +23 -0
- package/dist/idempotency/index.mjs +134 -0
- package/dist/{index-CmjNyulu.d.mts → index-SYF8N28D.d.mts} +37 -19
- package/dist/index.d.mts +105 -39
- package/dist/index.mjs +332 -676
- package/dist/indexes/verify-indexes.d.mts +14 -1
- package/dist/indexes/verify-indexes.mjs +54 -7
- package/dist/join-scope-DSAznE8E.mjs +168 -0
- package/dist/{mongooseToJsonSchema-waUVQwK4.mjs → mongooseToJsonSchema-C3k4ide0.mjs} +5 -1
- package/dist/{multi-tenant.plugin-B-tzNeYL.d.mts → multi-tenant.plugin-a3UhQARj.d.mts} +19 -3
- package/dist/{operations-CrRIoc8Z.d.mts → operations-XiFgG5US.d.mts} +36 -13
- package/dist/outbox/index.d.mts +1 -1
- package/dist/outbox/index.mjs +6 -4
- package/dist/pagination/PaginationEngine.d.mts +2 -2
- package/dist/pagination/PaginationEngine.mjs +1 -1
- package/dist/pagination/utils/limits.d.mts +1 -1
- package/dist/{pagination-BpIv2bdS.d.mts → pagination-D6psses0.d.mts} +116 -13
- package/dist/{plugin-options-BuvtOSZf.d.mts → plugin-options-CRhIvSzx.d.mts} +16 -2
- package/dist/plugins/index.d.mts +8 -3
- package/dist/plugins/index.mjs +98 -25
- package/dist/query-defaults-BxUcSOoF.mjs +208 -0
- package/dist/read-model/index.d.mts +66 -0
- package/dist/read-model/index.mjs +193 -0
- package/dist/{repository-ZbLtL7Nk.d.mts → repository-DNHfjAZX.d.mts} +36 -4
- package/dist/sync/index.d.mts +53 -0
- package/dist/sync/index.mjs +371 -0
- package/dist/tenant/index.mjs +2 -0
- package/dist/testkit/index.d.mts +40 -3
- package/dist/testkit/index.mjs +113 -2
- package/dist/{timestamp.plugin-B5eiaItQ.mjs → timestamp.plugin-zfdLYZiO.mjs} +135 -32
- package/dist/{to-plain-B8ufk3Qz.d.mts → to-plain-CjgEInWV.d.mts} +2 -2
- package/dist/transaction-BrRYtH_b.mjs +655 -0
- package/dist/{update-c_01A0if.mjs → update-WgeAUJ_I.mjs} +212 -33
- package/dist/utils/index.d.mts +1 -1
- package/dist/utils/index.mjs +1 -1
- package/dist/{validation-chain.plugin-Bju1D1Sn.mjs → validation-chain.plugin-CHUHWMtk.mjs} +204 -279
- package/dist/{validation-chain.plugin-DvrLJIbJ.d.mts → validation-chain.plugin-CTcNJCmV.d.mts} +27 -5
- package/package.json +18 -4
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { i as isValidIdForType, t as getSchemaIdType } from "./id-resolution-BqDrk0S2.mjs";
|
|
2
|
-
import { a as narrowToIds, i as keysetFilter, o as selectKeysetChunk } from "./id-chunks-BmAwsqNk.mjs";
|
|
3
|
-
import { _ as
|
|
2
|
+
import { a as narrowToIds, i as keysetFilter, o as selectKeysetChunk, r as idChunks, t as DEFAULT_ID_CHUNK } from "./id-chunks-BmAwsqNk.mjs";
|
|
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-WgeAUJ_I.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 { i as applyToQuery, n as applyCursorTimeoutMode, r as applyToAggregate, u as resolveQueryOptions } from "./query-defaults-BxUcSOoF.mjs";
|
|
7
|
+
import { a as createTxBoundRepo, d as resolveMongoCapabilities, i as withTransaction } from "./transaction-BrRYtH_b.mjs";
|
|
8
|
+
import { _ as resolveCursorSecrets, d as withGroupTiebreak, f as withIdTiebreak, g as attachSignature, i as validateKeysetSort, l as assertOffsetWithinCap, n as getPrimaryField, o as encodeCursor, s as resolveCursorFilter, t as PaginationEngine, u as cursorScope, v as verifySignature } from "./PaginationEngine-CfT2Cxkj.mjs";
|
|
7
9
|
import { calculateTotalPages } from "./pagination/utils/limits.mjs";
|
|
8
10
|
import { hasNearOperator, rewriteNearForCount } from "./query/primitives/geo.mjs";
|
|
9
|
-
import {
|
|
11
|
+
import { a as scopeJoins, r as markGoverned } from "./join-scope-DSAznE8E.mjs";
|
|
12
|
+
import { BULK_UPSERT_ERROR_CODES, BULK_UPSERT_FAILURE_CODES, RepositoryBase, bulkUpsertOptionsError, createDistributionGuard, runChunkedArchive, runChunkedPurge, summarizeBulkUpsert, throwIfAborted, validatePluginOrder, withRetry } from "@classytic/repo-core/repository";
|
|
10
13
|
import mongoose from "mongoose";
|
|
11
14
|
import { isFilter } from "@classytic/repo-core/filter";
|
|
12
15
|
import { VersionConflictError, conservativeMongoIsTransientConflict } from "@classytic/repo-core/errors";
|
|
@@ -118,257 +121,16 @@ async function purgeAnonymizeFunctional(repo, selectFilter, fields, limit, sessi
|
|
|
118
121
|
ordered: false,
|
|
119
122
|
session: session ?? void 0
|
|
120
123
|
});
|
|
124
|
+
await repo.emitAsync("after:anonymize", {
|
|
125
|
+
context: { ...session ? { session } : {} },
|
|
126
|
+
result: { ids: docs.map((doc) => doc._id) }
|
|
127
|
+
});
|
|
121
128
|
return {
|
|
122
129
|
processed: docs.length,
|
|
123
130
|
lastId: docs[docs.length - 1]?._id ?? null
|
|
124
131
|
};
|
|
125
132
|
}
|
|
126
133
|
//#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
134
|
//#region src/operations.ts
|
|
373
135
|
/**
|
|
374
136
|
* The registry. Order intentionally groups by category for readability.
|
|
@@ -431,6 +193,11 @@ const OP_REGISTRY = {
|
|
|
431
193
|
mutates: true,
|
|
432
194
|
hasIdContext: false
|
|
433
195
|
},
|
|
196
|
+
bulkUpsert: {
|
|
197
|
+
policyKey: "operations",
|
|
198
|
+
mutates: true,
|
|
199
|
+
hasIdContext: false
|
|
200
|
+
},
|
|
434
201
|
getById: {
|
|
435
202
|
policyKey: "query",
|
|
436
203
|
mutates: false,
|
|
@@ -486,6 +253,11 @@ const OP_REGISTRY = {
|
|
|
486
253
|
mutates: false,
|
|
487
254
|
hasIdContext: false
|
|
488
255
|
},
|
|
256
|
+
iterate: {
|
|
257
|
+
policyKey: "query",
|
|
258
|
+
mutates: false,
|
|
259
|
+
hasIdContext: false
|
|
260
|
+
},
|
|
489
261
|
watch: {
|
|
490
262
|
policyKey: "query",
|
|
491
263
|
mutates: false,
|
|
@@ -528,137 +300,32 @@ function applyExecutionHints(aggregation, hints) {
|
|
|
528
300
|
if (!hints) return;
|
|
529
301
|
if (hints.allowDiskUse) aggregation.allowDiskUse(true);
|
|
530
302
|
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;
|
|
303
|
+
if (hints.indexHint === void 0) return;
|
|
304
|
+
if (isExpectedIndex(hints.indexHint)) {
|
|
305
|
+
assertExpectedIndex(aggregation.model(), hints.indexHint.leadingKeys);
|
|
306
|
+
return;
|
|
575
307
|
}
|
|
308
|
+
aggregation.option({ hint: hints.indexHint });
|
|
576
309
|
}
|
|
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;
|
|
594
|
-
}
|
|
595
|
-
/**
|
|
596
|
-
* Cast a record-shape Mongo filter against a Mongoose schema so it is safe to
|
|
597
|
-
* hand to a `$match` stage. Recurses `$and` / `$or` / `$nor` / `$not`, since a
|
|
598
|
-
* policy/tenant scope conjoined with a caller filter nests one level down.
|
|
599
|
-
*
|
|
600
|
-
* Filter IR nodes and non-objects pass through untouched — IR values are built
|
|
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
|
-
* ```
|
|
609
|
-
*/
|
|
610
|
-
function castFilterToSchema(filter, schema) {
|
|
611
|
-
if (!isPlainObject(filter)) return filter;
|
|
612
|
-
if (typeof filter.op === "string") return filter;
|
|
613
|
-
let changed = false;
|
|
614
|
-
const out = {};
|
|
615
|
-
for (const [key, value] of Object.entries(filter)) {
|
|
616
|
-
if (LOGICAL_ARRAY_OPS.has(key) && Array.isArray(value)) {
|
|
617
|
-
const arr = value.map((entry) => castFilterToSchema(entry, schema));
|
|
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;
|
|
310
|
+
function isExpectedIndex(hint) {
|
|
311
|
+
return typeof hint === "object" && hint !== null && Array.isArray(hint.leadingKeys) && hint.leadingKeys.length > 0;
|
|
648
312
|
}
|
|
649
|
-
|
|
650
|
-
//#region src/actions/aggregate-ir/normalize.ts
|
|
313
|
+
const checked = /* @__PURE__ */ new WeakMap();
|
|
651
314
|
/**
|
|
652
|
-
*
|
|
653
|
-
*
|
|
654
|
-
*
|
|
655
|
-
*
|
|
656
|
-
*
|
|
657
|
-
* for what a valid AggRequest looks like change, update the IR
|
|
658
|
-
* contract there.
|
|
315
|
+
* Throw unless the model DECLARES an index whose key starts with `leadingKeys`, directly or after
|
|
316
|
+
* one leading scope field (a tenant / branch column the query pins by equality). Forcing such a
|
|
317
|
+
* description as a `hint` was wrong twice over: Mongo requires an index with EXACTLY that key, so a
|
|
318
|
+
* longer one failed `BadValue`; and a hint overrides the planner. The planner picks; this only
|
|
319
|
+
* guarantees the declared expectation holds. Checked once per model and key set.
|
|
659
320
|
*/
|
|
660
|
-
function
|
|
661
|
-
|
|
321
|
+
function assertExpectedIndex(model, leadingKeys) {
|
|
322
|
+
const id = leadingKeys.join(",");
|
|
323
|
+
const done = checked.get(model);
|
|
324
|
+
if (done?.has(id)) return;
|
|
325
|
+
const keys = model.schema.indexes().map(([fields]) => Object.keys(fields));
|
|
326
|
+
const startsWith = (key, at) => leadingKeys.every((k, i) => key[at + i] === k);
|
|
327
|
+
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.`);
|
|
328
|
+
checked.set(model, (done ?? /* @__PURE__ */ new Set()).add(id));
|
|
662
329
|
}
|
|
663
330
|
//#endregion
|
|
664
331
|
//#region src/actions/aggregate-ir/dateBucket.ts
|
|
@@ -831,11 +498,192 @@ function compileFilterToMongoExpr(input) {
|
|
|
831
498
|
*/
|
|
832
499
|
if (typeof input === "object") {
|
|
833
500
|
const keys = Object.keys(input);
|
|
501
|
+
/**
|
|
502
|
+
* Normalization is attempted FIRST, before the "all `$` keys means it is
|
|
503
|
+
* already an expression" fallback — because `{ $and: [...] }` satisfies
|
|
504
|
+
* both readings. As a query it is a conjunction of query objects; as an
|
|
505
|
+
* expression `$and` is a real operator whose operands would be those same
|
|
506
|
+
* objects, evaluated as TRUTHY. Taking the expression reading first made
|
|
507
|
+
* every `$and`/`$or` filter match all rows.
|
|
508
|
+
*
|
|
509
|
+
* Trying the query reading first disambiguates correctly: a genuine
|
|
510
|
+
* expression (`{ $eq: ['$a', 1] }`, or `$and` over expressions) fails to
|
|
511
|
+
* normalize — its operands are not field/operator objects — and falls
|
|
512
|
+
* through unchanged.
|
|
513
|
+
*/
|
|
514
|
+
const normalized = queryToFilterIr(input);
|
|
515
|
+
if (normalized) return compile(normalized);
|
|
834
516
|
if (keys.length > 0 && keys.every((k) => k.startsWith("$"))) return input;
|
|
835
|
-
throw createError(400, `mongokit/filter: \`where\`
|
|
517
|
+
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.`);
|
|
836
518
|
}
|
|
837
519
|
return input;
|
|
838
520
|
}
|
|
521
|
+
/**
|
|
522
|
+
* Operators a query object may use, mapped to their Filter IR op.
|
|
523
|
+
*
|
|
524
|
+
* Exactly the set `compileFilterToMongo`'s `expandShorthands` accepts, minus
|
|
525
|
+
* the ones the IR cannot express — so this is COMPLETE over its input, not a
|
|
526
|
+
* best-effort subset. Anything outside it reaches the refusal below rather
|
|
527
|
+
* than being quietly dropped.
|
|
528
|
+
*
|
|
529
|
+
* `mod` is the one shorthand with no IR node. It is refused by name, because
|
|
530
|
+
* "translated everything except the operator you used" is the failure this
|
|
531
|
+
* whole function exists to stop.
|
|
532
|
+
*/
|
|
533
|
+
/**
|
|
534
|
+
* Query operators this translates, and the Filter IR node each becomes.
|
|
535
|
+
*
|
|
536
|
+
* Exactly the set `compileFilterToMongo`'s `expandShorthands` accepts, minus
|
|
537
|
+
* the ones the IR cannot express — so the translation is COMPLETE over its
|
|
538
|
+
* input rather than a best-effort subset. Anything outside reaches the refusal
|
|
539
|
+
* below instead of being quietly dropped.
|
|
540
|
+
*
|
|
541
|
+
* Each node is BUILT, not spread from a common shape: the IR is not uniform —
|
|
542
|
+
* `in`/`nin` carry `values`, `exists` carries `exists`, `regex` carries
|
|
543
|
+
* `pattern`/`flags`. Emitting `value` for all of them compiled without
|
|
544
|
+
* complaint and then matched nothing, which the parity suite caught and a
|
|
545
|
+
* hand-written expectation would not have.
|
|
546
|
+
*/
|
|
547
|
+
const QUERY_OPS = {
|
|
548
|
+
eq: (field, value) => ({
|
|
549
|
+
op: "eq",
|
|
550
|
+
field,
|
|
551
|
+
value
|
|
552
|
+
}),
|
|
553
|
+
ne: (field, value) => ({
|
|
554
|
+
op: "ne",
|
|
555
|
+
field,
|
|
556
|
+
value
|
|
557
|
+
}),
|
|
558
|
+
gt: (field, value) => ({
|
|
559
|
+
op: "gt",
|
|
560
|
+
field,
|
|
561
|
+
value
|
|
562
|
+
}),
|
|
563
|
+
gte: (field, value) => ({
|
|
564
|
+
op: "gte",
|
|
565
|
+
field,
|
|
566
|
+
value
|
|
567
|
+
}),
|
|
568
|
+
lt: (field, value) => ({
|
|
569
|
+
op: "lt",
|
|
570
|
+
field,
|
|
571
|
+
value
|
|
572
|
+
}),
|
|
573
|
+
lte: (field, value) => ({
|
|
574
|
+
op: "lte",
|
|
575
|
+
field,
|
|
576
|
+
value
|
|
577
|
+
}),
|
|
578
|
+
in: (field, operand) => ({
|
|
579
|
+
op: "in",
|
|
580
|
+
field,
|
|
581
|
+
values: toArray(operand)
|
|
582
|
+
}),
|
|
583
|
+
nin: (field, operand) => ({
|
|
584
|
+
op: "nin",
|
|
585
|
+
field,
|
|
586
|
+
values: toArray(operand)
|
|
587
|
+
}),
|
|
588
|
+
exists: (field, operand) => ({
|
|
589
|
+
op: "exists",
|
|
590
|
+
field,
|
|
591
|
+
exists: operand !== false
|
|
592
|
+
}),
|
|
593
|
+
regex: (field, operand) => buildRegex(field, operand)
|
|
594
|
+
};
|
|
595
|
+
for (const name of Object.keys(QUERY_OPS)) QUERY_OPS[`$${name}`] = QUERY_OPS[name];
|
|
596
|
+
function toArray(operand) {
|
|
597
|
+
return Array.isArray(operand) ? [...operand] : [operand];
|
|
598
|
+
}
|
|
599
|
+
/** `regex` accepts a string, `{ $regex, $options }`, or a real RegExp. */
|
|
600
|
+
function buildRegex(field, operand) {
|
|
601
|
+
if (operand instanceof RegExp) return operand.flags ? {
|
|
602
|
+
op: "regex",
|
|
603
|
+
field,
|
|
604
|
+
pattern: operand.source,
|
|
605
|
+
flags: operand.flags
|
|
606
|
+
} : {
|
|
607
|
+
op: "regex",
|
|
608
|
+
field,
|
|
609
|
+
pattern: operand.source
|
|
610
|
+
};
|
|
611
|
+
if (operand !== null && typeof operand === "object") {
|
|
612
|
+
const o = operand;
|
|
613
|
+
const flags = typeof o.$options === "string" ? o.$options : void 0;
|
|
614
|
+
return flags ? {
|
|
615
|
+
op: "regex",
|
|
616
|
+
field,
|
|
617
|
+
pattern: String(o.$regex ?? ""),
|
|
618
|
+
flags
|
|
619
|
+
} : {
|
|
620
|
+
op: "regex",
|
|
621
|
+
field,
|
|
622
|
+
pattern: String(o.$regex ?? "")
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
return {
|
|
626
|
+
op: "regex",
|
|
627
|
+
field,
|
|
628
|
+
pattern: String(operand)
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
const LOGICAL = {
|
|
632
|
+
$and: "and",
|
|
633
|
+
$or: "or"
|
|
634
|
+
};
|
|
635
|
+
/** `{ field: { gte: 1, lte: 9 } }` and `{ a: 1, b: 2 }` are both conjunctions. */
|
|
636
|
+
function allOf(children) {
|
|
637
|
+
if (children.length === 0) return { op: "true" };
|
|
638
|
+
if (children.length === 1) return children[0];
|
|
639
|
+
return {
|
|
640
|
+
op: "and",
|
|
641
|
+
children
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* Translate a plain query object into Filter IR, or return `null` when it uses
|
|
646
|
+
* anything outside {@link QUERY_OPS}.
|
|
647
|
+
*
|
|
648
|
+
* `null` rather than a throw so the caller owns the error message — it has the
|
|
649
|
+
* context (which surface, what was passed) that makes the refusal actionable.
|
|
650
|
+
*/
|
|
651
|
+
function queryToFilterIr(query) {
|
|
652
|
+
const children = [];
|
|
653
|
+
for (const [key, value] of Object.entries(query)) {
|
|
654
|
+
const logical = LOGICAL[key];
|
|
655
|
+
if (logical) {
|
|
656
|
+
if (!Array.isArray(value)) return null;
|
|
657
|
+
const subs = [];
|
|
658
|
+
for (const entry of value) {
|
|
659
|
+
if (entry === null || typeof entry !== "object") return null;
|
|
660
|
+
const sub = queryToFilterIr(entry);
|
|
661
|
+
if (!sub) return null;
|
|
662
|
+
subs.push(sub);
|
|
663
|
+
}
|
|
664
|
+
children.push({
|
|
665
|
+
op: logical,
|
|
666
|
+
children: subs
|
|
667
|
+
});
|
|
668
|
+
continue;
|
|
669
|
+
}
|
|
670
|
+
if (key.startsWith("$")) return null;
|
|
671
|
+
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
672
|
+
const ops = Object.keys(value);
|
|
673
|
+
if (ops.length > 0 && ops.some((o) => o.startsWith("$") || SHORTHAND_OPS.has(o))) {
|
|
674
|
+
if (!ops.every((o) => o in QUERY_OPS)) return null;
|
|
675
|
+
for (const [op, operand] of Object.entries(value)) children.push(QUERY_OPS[op](key, operand));
|
|
676
|
+
continue;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
children.push({
|
|
680
|
+
op: "eq",
|
|
681
|
+
field: key,
|
|
682
|
+
value
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
return allOf(children);
|
|
686
|
+
}
|
|
839
687
|
function compile(filter) {
|
|
840
688
|
switch (filter.op) {
|
|
841
689
|
case "true": return true;
|
|
@@ -852,7 +700,25 @@ function compile(filter) {
|
|
|
852
700
|
case "nin":
|
|
853
701
|
if (filter.values.length === 0) return true;
|
|
854
702
|
return { $not: [{ $in: [`$${filter.field}`, [...filter.values]] }] };
|
|
855
|
-
case "exists":
|
|
703
|
+
case "exists": {
|
|
704
|
+
/**
|
|
705
|
+
* SQL parity: `exists: false` matches NULL and MISSING alike (`IS
|
|
706
|
+
* NULL`); `exists: true` matches neither (`IS NOT NULL`).
|
|
707
|
+
*
|
|
708
|
+
* This was `$ne: [field, null]` / `$eq: [field, null]`, on the stated
|
|
709
|
+
* grounds that "mongo treats [null and missing] the same way for missing
|
|
710
|
+
* fields". It does not: a missing path in an expression is MISSING, not
|
|
711
|
+
* null, and `$ne: ['$absent', null]` is TRUE. So `exists: true` matched
|
|
712
|
+
* every document including the ones without the field, and
|
|
713
|
+
* `exists: false` matched none — the predicate inverted on exactly the
|
|
714
|
+
* documents it was asked about.
|
|
715
|
+
*
|
|
716
|
+
* `$type` is the only operator that distinguishes the three states, and
|
|
717
|
+
* it names both of the ones SQL folds together.
|
|
718
|
+
*/
|
|
719
|
+
const nullish = { $in: [{ $type: `$${filter.field}` }, ["missing", "null"]] };
|
|
720
|
+
return filter.exists ? { $not: [nullish] } : nullish;
|
|
721
|
+
}
|
|
856
722
|
case "like": return { $regexMatch: {
|
|
857
723
|
input: `$${filter.field}`,
|
|
858
724
|
regex: likeToRegexPattern(filter.pattern),
|
|
@@ -977,6 +843,20 @@ function compileMeasure(measure) {
|
|
|
977
843
|
}
|
|
978
844
|
}
|
|
979
845
|
//#endregion
|
|
846
|
+
//#region src/actions/aggregate-ir/normalize.ts
|
|
847
|
+
/**
|
|
848
|
+
* AggRequest → input normalization (mongokit binding).
|
|
849
|
+
*
|
|
850
|
+
* Re-exports the kit-neutral helpers from `@classytic/repo-core/aggregate`
|
|
851
|
+
* with the mongokit error-prefix pre-bound, so call sites stay free of
|
|
852
|
+
* boilerplate. The actual logic lives in repo-core — when the rules
|
|
853
|
+
* for what a valid AggRequest looks like change, update the IR
|
|
854
|
+
* contract there.
|
|
855
|
+
*/
|
|
856
|
+
function validateMeasures$1(measures) {
|
|
857
|
+
validateMeasures(measures, "mongokit");
|
|
858
|
+
}
|
|
859
|
+
//#endregion
|
|
980
860
|
//#region src/actions/aggregate-ir/pipeline.ts
|
|
981
861
|
function buildAggPipeline(req, schema) {
|
|
982
862
|
validateMeasures$1(req.measures);
|
|
@@ -1371,19 +1251,34 @@ function irReferencesAlias(filter, aliasSet) {
|
|
|
1371
1251
|
return false;
|
|
1372
1252
|
}
|
|
1373
1253
|
//#endregion
|
|
1254
|
+
//#region src/actions/aggregate-ir/execute.ts
|
|
1255
|
+
/** Build the aggregate for `stages` with every execution option applied, unexecuted. */
|
|
1256
|
+
function prepareAgg(Model, stages, req, options = {}) {
|
|
1257
|
+
const aggregation = Model.aggregate(options.finalize ? options.finalize(stages) : stages);
|
|
1258
|
+
if (options.session) aggregation.session(options.session);
|
|
1259
|
+
applyToAggregate(aggregation, options.queryOptions ?? {});
|
|
1260
|
+
applyExecutionHints(aggregation, options.queryOptions && req.executionHints ? {
|
|
1261
|
+
...req.executionHints,
|
|
1262
|
+
maxTimeMs: void 0
|
|
1263
|
+
} : req.executionHints);
|
|
1264
|
+
return aggregation;
|
|
1265
|
+
}
|
|
1266
|
+
async function runAgg(Model, stages, req, options = {}) {
|
|
1267
|
+
return await prepareAgg(Model, stages, req, options).exec();
|
|
1268
|
+
}
|
|
1269
|
+
async function executeAgg(Model, req, options = {}) {
|
|
1270
|
+
const { pipeline } = buildAggPipeline(req, Model.schema);
|
|
1271
|
+
return runAgg(Model, pipeline, req, options);
|
|
1272
|
+
}
|
|
1273
|
+
//#endregion
|
|
1374
1274
|
//#region src/actions/aggregate-ir/count.ts
|
|
1375
1275
|
async function countAggGroups(Model, req, options = {}) {
|
|
1376
|
-
const session = options.session;
|
|
1377
1276
|
const groupCols = normalizeGroupBy(req.groupBy);
|
|
1378
1277
|
const hasDottedGroupBy = groupCols.some((f) => f.includes("."));
|
|
1379
1278
|
const hasDateBuckets = !!req.dateBuckets && Object.keys(req.dateBuckets).length > 0;
|
|
1380
1279
|
if (req.having !== void 0 || req.lookups !== void 0 && req.lookups.length > 0 || hasDottedGroupBy || hasDateBuckets) {
|
|
1381
1280
|
const { pipeline, prePaginationIndex } = buildAggPipeline(req, Model.schema);
|
|
1382
|
-
const
|
|
1383
|
-
const aggregation = Model.aggregate(finalPipeline);
|
|
1384
|
-
if (session) aggregation.session(session);
|
|
1385
|
-
applyExecutionHints(aggregation, req.executionHints);
|
|
1386
|
-
const [row] = await aggregation.exec();
|
|
1281
|
+
const [row] = await runAgg(Model, [...pipeline.slice(0, prePaginationIndex), { $count: "n" }], req, options);
|
|
1387
1282
|
return row?.n ?? 0;
|
|
1388
1283
|
}
|
|
1389
1284
|
const match = req.filter ? compileFilterToMongo(castFilterToSchema(req.filter, Model.schema), Model.schema) : {};
|
|
@@ -1392,10 +1287,7 @@ async function countAggGroups(Model, req, options = {}) {
|
|
|
1392
1287
|
if (Object.keys(match).length > 0) pipeline.push({ $match: match });
|
|
1393
1288
|
pipeline.push({ $limit: 1 });
|
|
1394
1289
|
pipeline.push({ $count: "n" });
|
|
1395
|
-
const
|
|
1396
|
-
if (session) aggregation.session(session);
|
|
1397
|
-
applyExecutionHints(aggregation, req.executionHints);
|
|
1398
|
-
const [row] = await aggregation.exec();
|
|
1290
|
+
const [row] = await runAgg(Model, pipeline, req, options);
|
|
1399
1291
|
return row?.n ?? 0;
|
|
1400
1292
|
}
|
|
1401
1293
|
const groupId = groupCols.reduce((acc, field) => {
|
|
@@ -1406,22 +1298,10 @@ async function countAggGroups(Model, req, options = {}) {
|
|
|
1406
1298
|
if (Object.keys(match).length > 0) pipeline.push({ $match: match });
|
|
1407
1299
|
pipeline.push({ $group: { _id: groupId } });
|
|
1408
1300
|
pipeline.push({ $count: "n" });
|
|
1409
|
-
const
|
|
1410
|
-
if (session) aggregation.session(session);
|
|
1411
|
-
const [row] = await aggregation.exec();
|
|
1301
|
+
const [row] = await runAgg(Model, pipeline, req, options);
|
|
1412
1302
|
return row?.n ?? 0;
|
|
1413
1303
|
}
|
|
1414
1304
|
//#endregion
|
|
1415
|
-
//#region src/actions/aggregate-ir/execute.ts
|
|
1416
|
-
async function executeAgg(Model, req, options = {}) {
|
|
1417
|
-
const session = options.session;
|
|
1418
|
-
const { pipeline } = buildAggPipeline(req, Model.schema);
|
|
1419
|
-
const aggregation = Model.aggregate(pipeline);
|
|
1420
|
-
if (session) aggregation.session(session);
|
|
1421
|
-
applyExecutionHints(aggregation, req.executionHints);
|
|
1422
|
-
return await aggregation.exec();
|
|
1423
|
-
}
|
|
1424
|
-
//#endregion
|
|
1425
1305
|
//#region src/actions/aggregate-ir/keyset.ts
|
|
1426
1306
|
/**
|
|
1427
1307
|
* Keyset (cursor) pagination helpers — mongokit binding.
|
|
@@ -1507,6 +1387,188 @@ function createMongoArchivePort(repo, filter, session) {
|
|
|
1507
1387
|
};
|
|
1508
1388
|
}
|
|
1509
1389
|
//#endregion
|
|
1390
|
+
//#region src/actions/bulk-upsert.ts
|
|
1391
|
+
/**
|
|
1392
|
+
* `bulkUpsert` execution: keyed `updateOne` + `upsert` sub-ops in ONE `client.bulkWrite` with
|
|
1393
|
+
* `verboseResults`, the only form that reports matched / modified / upserted PER OP (server 8.0,
|
|
1394
|
+
* driver ClientBulkWrite). Mongoose's unordered `Connection.bulkWrite` returns without rethrowing a
|
|
1395
|
+
* driver error (lib/connection.js, the `[res, error]` branch), so it is not used. Each row is cast
|
|
1396
|
+
* with `Model.castObject` (a bad value fails that row as `validation`) and schema timestamps are
|
|
1397
|
+
* stamped here, since the driver path runs no mongoose middleware.
|
|
1398
|
+
*/
|
|
1399
|
+
/** Split rows into sendable sub-ops and rows refused before sending (`invalid_row`). */
|
|
1400
|
+
function planUpserts(rows, key, routing) {
|
|
1401
|
+
const planned = [];
|
|
1402
|
+
const refused = [];
|
|
1403
|
+
rows.forEach((row, index) => {
|
|
1404
|
+
const keyVals = Object.fromEntries(key.map((k) => [k, row[k]]));
|
|
1405
|
+
const missingKey = key.some((k) => row[k] === void 0 || row[k] === null);
|
|
1406
|
+
const badInc = routing.inc.some((f) => row[f] !== void 0 && typeof row[f] !== "number");
|
|
1407
|
+
if (missingKey || badInc) {
|
|
1408
|
+
refused.push({
|
|
1409
|
+
index,
|
|
1410
|
+
key: keyVals,
|
|
1411
|
+
outcome: "failed",
|
|
1412
|
+
code: BULK_UPSERT_FAILURE_CODES.INVALID_ROW
|
|
1413
|
+
});
|
|
1414
|
+
return;
|
|
1415
|
+
}
|
|
1416
|
+
const $set = {};
|
|
1417
|
+
const $inc = {};
|
|
1418
|
+
const $setOnInsert = {};
|
|
1419
|
+
for (const [field, value] of Object.entries(row)) {
|
|
1420
|
+
if (key.includes(field) || value === void 0) continue;
|
|
1421
|
+
if (routing.inc.includes(field)) $inc[field] = value;
|
|
1422
|
+
else if (routing.setOnInsert.includes(field)) $setOnInsert[field] = value;
|
|
1423
|
+
else if (!routing.set || routing.set.includes(field)) $set[field] = value;
|
|
1424
|
+
}
|
|
1425
|
+
const update = {};
|
|
1426
|
+
if (Object.keys($set).length > 0) update.$set = $set;
|
|
1427
|
+
if (Object.keys($inc).length > 0) update.$inc = $inc;
|
|
1428
|
+
if (Object.keys($setOnInsert).length > 0) update.$setOnInsert = $setOnInsert;
|
|
1429
|
+
if (Object.keys(update).length === 0) update.$set = {};
|
|
1430
|
+
planned.push({
|
|
1431
|
+
index,
|
|
1432
|
+
key: keyVals,
|
|
1433
|
+
filter: { ...keyVals },
|
|
1434
|
+
update
|
|
1435
|
+
});
|
|
1436
|
+
});
|
|
1437
|
+
return {
|
|
1438
|
+
planned,
|
|
1439
|
+
refused
|
|
1440
|
+
};
|
|
1441
|
+
}
|
|
1442
|
+
/** Field sets a unique index covers; `_id` always. */
|
|
1443
|
+
function uniqueIndexes(model) {
|
|
1444
|
+
const out = [["_id"]];
|
|
1445
|
+
for (const [fields, options] of model.schema.indexes()) if (options.unique === true) out.push(Object.keys(fields));
|
|
1446
|
+
return out;
|
|
1447
|
+
}
|
|
1448
|
+
/** Is some unique index entirely pinned by the filter's equality fields (so it matches <= 1 row)? */
|
|
1449
|
+
function keyIsUnique(model, filterFields) {
|
|
1450
|
+
return uniqueIndexes(model).some((fields) => fields.every((f) => filterFields.includes(f)));
|
|
1451
|
+
}
|
|
1452
|
+
function timestampFields(model) {
|
|
1453
|
+
const ts = model.schema.get("timestamps");
|
|
1454
|
+
if (!ts) return {};
|
|
1455
|
+
if (ts === true) return {
|
|
1456
|
+
createdAt: "createdAt",
|
|
1457
|
+
updatedAt: "updatedAt"
|
|
1458
|
+
};
|
|
1459
|
+
const name = (v, d) => v === false ? void 0 : typeof v === "string" ? v : d;
|
|
1460
|
+
return {
|
|
1461
|
+
createdAt: name(ts.createdAt, "createdAt"),
|
|
1462
|
+
updatedAt: name(ts.updatedAt, "updatedAt")
|
|
1463
|
+
};
|
|
1464
|
+
}
|
|
1465
|
+
function isRecord(value) {
|
|
1466
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1467
|
+
}
|
|
1468
|
+
/** `Model.castObject`, narrowed to the plain object it returns for a plain object input. */
|
|
1469
|
+
function castRecord(model, fields) {
|
|
1470
|
+
const casted = model.castObject(fields);
|
|
1471
|
+
if (!isRecord(casted)) throw new TypeError("[mongokit] castObject returned a non-object");
|
|
1472
|
+
return casted;
|
|
1473
|
+
}
|
|
1474
|
+
/** A server before 8.0 has no `bulkWrite` command (CommandNotFound, 59): a closed refusal. */
|
|
1475
|
+
function throwIfUnsupported(err) {
|
|
1476
|
+
if (err instanceof mongoose.mongo.MongoServerError && err.code === 59) throw createError(501, "[mongokit] bulkUpsert needs MongoDB 8.0 (client bulkWrite with per-op results)", { code: BULK_UPSERT_ERROR_CODES.UNSUPPORTED });
|
|
1477
|
+
}
|
|
1478
|
+
function failureCode(code) {
|
|
1479
|
+
if (code === 11e3 || code === 11001) return BULK_UPSERT_FAILURE_CODES.DUPLICATE;
|
|
1480
|
+
if (code === 121) return BULK_UPSERT_FAILURE_CODES.VALIDATION;
|
|
1481
|
+
return BULK_UPSERT_FAILURE_CODES.WRITE_ERROR;
|
|
1482
|
+
}
|
|
1483
|
+
/** Cast, stamp, send in one command, and map every sub-op to its exact outcome. */
|
|
1484
|
+
async function executeUpserts(model, ops, options) {
|
|
1485
|
+
const results = [];
|
|
1486
|
+
const sendable = [];
|
|
1487
|
+
const namespace = `${model.db.name}.${model.collection.collectionName}`;
|
|
1488
|
+
const ts = timestampFields(model);
|
|
1489
|
+
const now = /* @__PURE__ */ new Date();
|
|
1490
|
+
for (const op of ops) try {
|
|
1491
|
+
const update = {};
|
|
1492
|
+
for (const [operator, fields] of Object.entries(op.update)) update[operator] = Object.keys(fields).length > 0 ? castRecord(model, fields) : {};
|
|
1493
|
+
if (ts.updatedAt) update.$set = {
|
|
1494
|
+
...update.$set,
|
|
1495
|
+
[ts.updatedAt]: now
|
|
1496
|
+
};
|
|
1497
|
+
if (ts.createdAt) update.$setOnInsert = {
|
|
1498
|
+
...update.$setOnInsert,
|
|
1499
|
+
[ts.createdAt]: now
|
|
1500
|
+
};
|
|
1501
|
+
sendable.push({
|
|
1502
|
+
...op,
|
|
1503
|
+
filter: castRecord(model, op.filter),
|
|
1504
|
+
update
|
|
1505
|
+
});
|
|
1506
|
+
} catch (err) {
|
|
1507
|
+
if (!(err instanceof mongoose.Error.ValidationError) && !(err instanceof mongoose.Error.CastError)) throw err;
|
|
1508
|
+
results.push({
|
|
1509
|
+
index: op.index,
|
|
1510
|
+
key: op.key,
|
|
1511
|
+
outcome: "failed",
|
|
1512
|
+
code: BULK_UPSERT_FAILURE_CODES.VALIDATION
|
|
1513
|
+
});
|
|
1514
|
+
}
|
|
1515
|
+
if (sendable.length === 0) return results;
|
|
1516
|
+
const models = sendable.map((op) => ({
|
|
1517
|
+
name: "updateOne",
|
|
1518
|
+
namespace,
|
|
1519
|
+
filter: op.filter,
|
|
1520
|
+
update: op.update,
|
|
1521
|
+
upsert: true
|
|
1522
|
+
}));
|
|
1523
|
+
let res;
|
|
1524
|
+
let writeErrors = /* @__PURE__ */ new Map();
|
|
1525
|
+
try {
|
|
1526
|
+
res = await model.db.getClient().bulkWrite(models, {
|
|
1527
|
+
ordered: options.ordered,
|
|
1528
|
+
verboseResults: true,
|
|
1529
|
+
...options.session ? { session: options.session } : {},
|
|
1530
|
+
...options.writeConcern ? { writeConcern: options.writeConcern } : {},
|
|
1531
|
+
...options.timeoutMS !== void 0 ? { timeoutMS: options.timeoutMS } : {}
|
|
1532
|
+
});
|
|
1533
|
+
} catch (err) {
|
|
1534
|
+
throwIfUnsupported(err);
|
|
1535
|
+
if (!(err instanceof mongoose.mongo.MongoClientBulkWriteError) || err.writeConcernErrors.length > 0) throw err;
|
|
1536
|
+
res = err.partialResult;
|
|
1537
|
+
writeErrors = err.writeErrors;
|
|
1538
|
+
}
|
|
1539
|
+
const updates = res?.updateResults ?? /* @__PURE__ */ new Map();
|
|
1540
|
+
sendable.forEach((op, i) => {
|
|
1541
|
+
const failed = writeErrors.get(i);
|
|
1542
|
+
if (failed) {
|
|
1543
|
+
results.push({
|
|
1544
|
+
index: op.index,
|
|
1545
|
+
key: op.key,
|
|
1546
|
+
outcome: "failed",
|
|
1547
|
+
code: failureCode(failed.code)
|
|
1548
|
+
});
|
|
1549
|
+
return;
|
|
1550
|
+
}
|
|
1551
|
+
const r = updates.get(i);
|
|
1552
|
+
if (!r) {
|
|
1553
|
+
if (!options.ordered) throw new Error(`[mongokit] bulkUpsert: the server reported no outcome for row ${op.index}`);
|
|
1554
|
+
results.push({
|
|
1555
|
+
index: op.index,
|
|
1556
|
+
key: op.key,
|
|
1557
|
+
outcome: "failed",
|
|
1558
|
+
code: BULK_UPSERT_FAILURE_CODES.NOT_ATTEMPTED
|
|
1559
|
+
});
|
|
1560
|
+
return;
|
|
1561
|
+
}
|
|
1562
|
+
const outcome = r.upsertedId !== void 0 && r.upsertedId !== null ? "inserted" : r.modifiedCount > 0 ? "updated" : "unchanged";
|
|
1563
|
+
results.push({
|
|
1564
|
+
index: op.index,
|
|
1565
|
+
key: op.key,
|
|
1566
|
+
outcome
|
|
1567
|
+
});
|
|
1568
|
+
});
|
|
1569
|
+
return results;
|
|
1570
|
+
}
|
|
1571
|
+
//#endregion
|
|
1510
1572
|
//#region src/query/AggregationBuilder.ts
|
|
1511
1573
|
/**
|
|
1512
1574
|
* Normalize SortSpec to MongoDB's strict format (1 | -1)
|
|
@@ -2092,242 +2154,6 @@ function appendLookupStages(pipeline, lookups, projection, keysetSort) {
|
|
|
2092
2154
|
if (finalProjection) pipeline.push({ $project: finalProjection });
|
|
2093
2155
|
}
|
|
2094
2156
|
//#endregion
|
|
2095
|
-
//#region src/tx-bound.ts
|
|
2096
|
-
/**
|
|
2097
|
-
* Map of session-aware method name → positional index of the `{ session }`
|
|
2098
|
-
* options bag. Keep this list in sync with the method signatures in
|
|
2099
|
-
* `Repository.ts` and the plugin method definitions. Unknown plugin
|
|
2100
|
-
* methods pass through unwrapped (callers use the standalone helper).
|
|
2101
|
-
*/
|
|
2102
|
-
const SESSION_OPTIONS_INDEX = Object.freeze({
|
|
2103
|
-
create: 1,
|
|
2104
|
-
update: 2,
|
|
2105
|
-
delete: 1,
|
|
2106
|
-
getById: 1,
|
|
2107
|
-
getAll: 1,
|
|
2108
|
-
createMany: 1,
|
|
2109
|
-
findAll: 1,
|
|
2110
|
-
getOrCreate: 2,
|
|
2111
|
-
count: 1,
|
|
2112
|
-
exists: 1,
|
|
2113
|
-
getByQuery: 1,
|
|
2114
|
-
getOne: 1,
|
|
2115
|
-
findOneAndUpdate: 2,
|
|
2116
|
-
distinct: 2,
|
|
2117
|
-
aggregate: 1,
|
|
2118
|
-
aggregatePaginate: 0,
|
|
2119
|
-
lookupPopulate: 0,
|
|
2120
|
-
upsert: 2,
|
|
2121
|
-
increment: 3,
|
|
2122
|
-
decrement: 3,
|
|
2123
|
-
multiplyField: 3,
|
|
2124
|
-
setMin: 3,
|
|
2125
|
-
setMax: 3,
|
|
2126
|
-
pushToArray: 3,
|
|
2127
|
-
pullFromArray: 3,
|
|
2128
|
-
addToSet: 3,
|
|
2129
|
-
setField: 3,
|
|
2130
|
-
unsetField: 2,
|
|
2131
|
-
renameField: 3,
|
|
2132
|
-
atomicUpdate: 2,
|
|
2133
|
-
updateMany: 2,
|
|
2134
|
-
deleteMany: 1,
|
|
2135
|
-
bulkWrite: 1,
|
|
2136
|
-
restore: 1,
|
|
2137
|
-
getDeleted: 1,
|
|
2138
|
-
addSubdocument: 3,
|
|
2139
|
-
getSubdocument: 3,
|
|
2140
|
-
updateSubdocument: 4,
|
|
2141
|
-
deleteSubdocument: 3,
|
|
2142
|
-
groupBy: 1,
|
|
2143
|
-
sum: 2,
|
|
2144
|
-
average: 2,
|
|
2145
|
-
min: 2,
|
|
2146
|
-
max: 2
|
|
2147
|
-
});
|
|
2148
|
-
/**
|
|
2149
|
-
* Build a session-threaded proxy over `outer`. The returned object has the
|
|
2150
|
-
* same method signatures, but every CRUD call auto-injects the supplied
|
|
2151
|
-
* session into the options bag. Non-CRUD properties (Model, modelName,
|
|
2152
|
-
* hook API, utility helpers) pass through.
|
|
2153
|
-
*/
|
|
2154
|
-
function createTxBoundRepo(outer, session) {
|
|
2155
|
-
return new Proxy(outer, { get(target, prop, receiver) {
|
|
2156
|
-
if (prop === "withTransaction") return () => {
|
|
2157
|
-
throw new Error("[mongokit] Nested withTransaction is not supported on a tx-bound repository. Reuse the outer `txRepo` directly, or collapse the nesting.");
|
|
2158
|
-
};
|
|
2159
|
-
const value = Reflect.get(target, prop, receiver);
|
|
2160
|
-
if (typeof value !== "function") return value;
|
|
2161
|
-
if (prop === "Model") return value;
|
|
2162
|
-
if (typeof prop === "symbol") return value.bind(target);
|
|
2163
|
-
if (prop.startsWith("_")) return value.bind(target);
|
|
2164
|
-
const optionsIndex = SESSION_OPTIONS_INDEX[prop];
|
|
2165
|
-
if (optionsIndex === void 0) return value.bind(target);
|
|
2166
|
-
return function txBoundMethod(...args) {
|
|
2167
|
-
while (args.length <= optionsIndex) args.push(void 0);
|
|
2168
|
-
const current = args[optionsIndex];
|
|
2169
|
-
if (current === void 0) args[optionsIndex] = { session };
|
|
2170
|
-
else if (typeof current === "object" && current !== null && !Array.isArray(current)) args[optionsIndex] = {
|
|
2171
|
-
...current,
|
|
2172
|
-
session
|
|
2173
|
-
};
|
|
2174
|
-
else return value.apply(target, args);
|
|
2175
|
-
return value.apply(target, args);
|
|
2176
|
-
};
|
|
2177
|
-
} });
|
|
2178
|
-
}
|
|
2179
|
-
//#endregion
|
|
2180
|
-
//#region src/transaction.ts
|
|
2181
|
-
/**
|
|
2182
|
-
* Run a callback inside a MongoDB transaction on the given connection.
|
|
2183
|
-
*
|
|
2184
|
-
* - Starts a session, runs `session.withTransaction(callback)` (which auto-retries
|
|
2185
|
-
* on `TransientTransactionError` and `UnknownTransactionCommitResult`), and
|
|
2186
|
-
* always ends the session in `finally`.
|
|
2187
|
-
* - When `allowFallback` is true and the deployment doesn't support transactions
|
|
2188
|
-
* (e.g. standalone MongoDB in dev), the callback runs once without a
|
|
2189
|
-
* transaction on the same session. `onFallback` is invoked with the original
|
|
2190
|
-
* error so hosts can log the degradation.
|
|
2191
|
-
*
|
|
2192
|
-
* @example
|
|
2193
|
-
* ```ts
|
|
2194
|
-
* import mongoose from 'mongoose';
|
|
2195
|
-
* import { withTransaction } from '@classytic/mongokit';
|
|
2196
|
-
*
|
|
2197
|
-
* await withTransaction(mongoose.connection, async (session) => {
|
|
2198
|
-
* const order = await orderRepo.create(data, { session });
|
|
2199
|
-
* await inventoryRepo.decrement(order.items, { session });
|
|
2200
|
-
* return order;
|
|
2201
|
-
* });
|
|
2202
|
-
* ```
|
|
2203
|
-
*/
|
|
2204
|
-
async function withTransaction(connection, callback, options = {}) {
|
|
2205
|
-
const session = await connection.startSession();
|
|
2206
|
-
try {
|
|
2207
|
-
return await session.withTransaction(() => callback(session), options.transactionOptions);
|
|
2208
|
-
} catch (error) {
|
|
2209
|
-
const err = error;
|
|
2210
|
-
if (options.allowFallback && isTransactionUnsupported(err)) {
|
|
2211
|
-
options.onFallback?.(err);
|
|
2212
|
-
return await callback(session);
|
|
2213
|
-
}
|
|
2214
|
-
throw err;
|
|
2215
|
-
} finally {
|
|
2216
|
-
await session.endSession();
|
|
2217
|
-
}
|
|
2218
|
-
}
|
|
2219
|
-
/**
|
|
2220
|
-
* Multi-repo transactional batch — every repo in `repos` becomes a
|
|
2221
|
-
* session-bound proxy inside the callback. Eliminates the per-call
|
|
2222
|
-
* `{ session }` threading that was repeated across ~20 call sites
|
|
2223
|
-
* (be-prod outbox writes, order placement, transfer source/dest pairs,
|
|
2224
|
-
* payrun saga steps).
|
|
2225
|
-
*
|
|
2226
|
-
* Same retry + fallback semantics as `withTransaction`: the
|
|
2227
|
-
* `transactionOptions` / `allowFallback` / `onFallback` knobs apply.
|
|
2228
|
-
*
|
|
2229
|
-
* Each property of the input `repos` map is rebound via the
|
|
2230
|
-
* `createTxBoundRepo` proxy — every CRUD method on the bound repo
|
|
2231
|
-
* auto-injects `session` into its options bag, including
|
|
2232
|
-
* `claim` / `claimVersion` / `findOneAndUpdate` and plugin-contributed
|
|
2233
|
-
* methods. Non-CRUD properties (Model, modelName, hook engine,
|
|
2234
|
-
* idField) pass through to the underlying repo. Nested
|
|
2235
|
-
* `boundRepo.withTransaction(...)` throws — reuse the outer bound
|
|
2236
|
-
* repos.
|
|
2237
|
-
*
|
|
2238
|
-
* @example Order placement across three repos in one transaction
|
|
2239
|
-
* ```ts
|
|
2240
|
-
* import { batchTransaction } from '@classytic/mongokit';
|
|
2241
|
-
*
|
|
2242
|
-
* const order = await batchTransaction(
|
|
2243
|
-
* mongoose.connection,
|
|
2244
|
-
* { orders: orderRepo, events: eventRepo, inventory: inventoryRepo },
|
|
2245
|
-
* async ({ orders, events, inventory }) => {
|
|
2246
|
-
* const created = await orders.create(orderData); // session auto-injected
|
|
2247
|
-
* await events.create({ type: 'order.placed', orderId: created._id });
|
|
2248
|
-
* await inventory.claim(skuId, { from: 'available', to: 'reserved' });
|
|
2249
|
-
* return created;
|
|
2250
|
-
* },
|
|
2251
|
-
* );
|
|
2252
|
-
* ```
|
|
2253
|
-
*
|
|
2254
|
-
* @example With fallback for standalone-mongo dev environments
|
|
2255
|
-
* ```ts
|
|
2256
|
-
* await batchTransaction(
|
|
2257
|
-
* mongoose.connection,
|
|
2258
|
-
* { orders, events },
|
|
2259
|
-
* async ({ orders, events }) => { ... },
|
|
2260
|
-
* { allowFallback: true, onFallback: (err) => log.warn(err) },
|
|
2261
|
-
* );
|
|
2262
|
-
* ```
|
|
2263
|
-
*
|
|
2264
|
-
* @param connection - Mongoose connection (or anything with
|
|
2265
|
-
* `startSession()`). Single source of session truth — every bound
|
|
2266
|
-
* repo shares this session.
|
|
2267
|
-
* @param repos - Map of repo instances to rebind. Keys become the
|
|
2268
|
-
* property names on the callback's argument.
|
|
2269
|
-
* @param callback - Receives the bound repo map. Return value flows
|
|
2270
|
-
* through to the outer `Promise`.
|
|
2271
|
-
*/
|
|
2272
|
-
async function batchTransaction(connection, repos, callback, options = {}) {
|
|
2273
|
-
return withTransaction(connection, async (session) => {
|
|
2274
|
-
const bound = {};
|
|
2275
|
-
for (const key of Object.keys(repos)) {
|
|
2276
|
-
const repo = repos[key];
|
|
2277
|
-
bound[key] = createTxBoundRepo(repo, session);
|
|
2278
|
-
}
|
|
2279
|
-
return callback(bound);
|
|
2280
|
-
}, options);
|
|
2281
|
-
}
|
|
2282
|
-
/**
|
|
2283
|
-
* Detect whether an error indicates the MongoDB deployment does not support
|
|
2284
|
-
* multi-document transactions (standalone server, older topology, etc.).
|
|
2285
|
-
*
|
|
2286
|
-
* Checks MongoDB error codes first — 263 (standalone) and 20 (unsupported
|
|
2287
|
-
* topology) — with a message-matching fallback for edge cases surfaced by
|
|
2288
|
-
* driver versions that throw before the proper code lands.
|
|
2289
|
-
*
|
|
2290
|
-
* **Driver-version drift:** modern mongoose / mongodb-driver versions hit
|
|
2291
|
-
* the standalone case via the retryable-writes precondition rather than
|
|
2292
|
-
* the transaction precondition (`retryWrites=true` is on by default,
|
|
2293
|
-
* standalone Mongo rejects it before the transaction is even attempted).
|
|
2294
|
-
* So we accept that message as equivalent — the underlying topology
|
|
2295
|
-
* problem is the same, and the fallback semantics are correct either way.
|
|
2296
|
-
*/
|
|
2297
|
-
function isTransactionUnsupported(error) {
|
|
2298
|
-
const code = error.code;
|
|
2299
|
-
if (code === 263 || code === 20) return true;
|
|
2300
|
-
const message = (error.message || "").toLowerCase();
|
|
2301
|
-
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");
|
|
2302
|
-
}
|
|
2303
|
-
/**
|
|
2304
|
-
* PROACTIVELY report whether a connection's deployment supports multi-document
|
|
2305
|
-
* transactions — the read-side companion to {@link isTransactionUnsupported}
|
|
2306
|
-
* (which classifies a FAILED attempt). Lets a caller skip a doomed transaction
|
|
2307
|
-
* attempt on standalone dev Mongo rather than starting one just to catch the
|
|
2308
|
-
* error.
|
|
2309
|
-
*
|
|
2310
|
-
* Delegates to {@link resolveTransactionSupport} so mongokit has exactly ONE
|
|
2311
|
-
* topology reader. That also fixes a false NEGATIVE this function used to
|
|
2312
|
-
* have: it tested `description.type !== 'Single'`, but a single-node replica
|
|
2313
|
-
* set reached with `directConnection` is `Single` with a server type of
|
|
2314
|
-
* `RSPrimary` and runs transactions fine. The shared reader looks at the
|
|
2315
|
-
* SERVER descriptions first.
|
|
2316
|
-
*
|
|
2317
|
-
* **`unknown` is optimistic HERE and pessimistic in `capabilities.ts` — the
|
|
2318
|
-
* difference is deliberate.** This answers "should I bother ATTEMPTING a
|
|
2319
|
-
* transaction?", where guessing yes costs one caught error (`allowFallback`
|
|
2320
|
-
* still recovers). The capability descriptor answers "may this deployment be
|
|
2321
|
-
* trusted with money?", where guessing yes is the bug class this repo keeps
|
|
2322
|
-
* hitting (AGENTS.md FAIL LOUD rule 3). Do not "unify" them.
|
|
2323
|
-
*
|
|
2324
|
-
* Accepts a Mongoose `Connection` (or anything exposing `getClient()`/`client`
|
|
2325
|
-
* with a driver `topology`), so it stays decoupled from mongoose types.
|
|
2326
|
-
*/
|
|
2327
|
-
function supportsTransactions(connection) {
|
|
2328
|
-
return resolveTransactionSupport(connection) !== "no";
|
|
2329
|
-
}
|
|
2330
|
-
//#endregion
|
|
2331
2157
|
//#region src/Repository.ts
|
|
2332
2158
|
/**
|
|
2333
2159
|
* Validates that an update patch is homogeneous — either all top-level
|
|
@@ -2462,7 +2288,7 @@ const HOOK_PRIORITY$1 = HOOK_PRIORITY;
|
|
|
2462
2288
|
*/
|
|
2463
2289
|
function downgradeCappedCount(strategy, where) {
|
|
2464
2290
|
if (strategy !== "capped") return strategy;
|
|
2465
|
-
warn(`[mongokit] countStrategy 'capped' is not supported by ${where} — counting exactly.
|
|
2291
|
+
warn(`[mongokit] countStrategy 'capped' is not supported by ${where} — counting exactly. Group counts are exact here: a bounded group count would need its own estimate flag. Use getAll()/aggregatePaginate() where a bounded count matters.`);
|
|
2466
2292
|
return "exact";
|
|
2467
2293
|
}
|
|
2468
2294
|
var Repository = class extends RepositoryBase {
|
|
@@ -2528,6 +2354,8 @@ var Repository = class extends RepositoryBase {
|
|
|
2528
2354
|
* {@link ID_ADDRESSED_OPERATIONS}.
|
|
2529
2355
|
*/
|
|
2530
2356
|
_guardIdAddressed = false;
|
|
2357
|
+
/** This repository's own `queryDefaults` / `aggregateDefaults` (deployment ones fill the gaps). */
|
|
2358
|
+
_queryDefaults;
|
|
2531
2359
|
constructor(Model, plugins = [], paginationConfig = {}, options = {}) {
|
|
2532
2360
|
super({
|
|
2533
2361
|
name: Model.modelName,
|
|
@@ -2541,6 +2369,11 @@ var Repository = class extends RepositoryBase {
|
|
|
2541
2369
|
warn(m);
|
|
2542
2370
|
});
|
|
2543
2371
|
this.Model = Model;
|
|
2372
|
+
markGoverned(Model);
|
|
2373
|
+
this._queryDefaults = {
|
|
2374
|
+
query: options.queryDefaults,
|
|
2375
|
+
aggregate: options.aggregateDefaults
|
|
2376
|
+
};
|
|
2544
2377
|
this._capabilitiesOverride = options.capabilities;
|
|
2545
2378
|
this.model = Model.modelName;
|
|
2546
2379
|
this._pagination = new PaginationEngine(Model, paginationConfig);
|
|
@@ -2785,6 +2618,31 @@ var Repository = class extends RepositoryBase {
|
|
|
2785
2618
|
* `_buildContext` inputs), so every op routed through `_runOp` — and the
|
|
2786
2619
|
* inline-try methods that call this directly — honors them uniformly.
|
|
2787
2620
|
*/
|
|
2621
|
+
/** One command's time bound and concerns: per call, then this repository, then the deployment. */
|
|
2622
|
+
_opts(kind, perCall) {
|
|
2623
|
+
return resolveQueryOptions(kind, perCall, this._queryDefaults);
|
|
2624
|
+
}
|
|
2625
|
+
/** How an IR request runs: per-call hints, then this repository's and the deployment's defaults. */
|
|
2626
|
+
_aggRun(req, context) {
|
|
2627
|
+
return {
|
|
2628
|
+
session: context.session,
|
|
2629
|
+
queryOptions: this._opts("aggregate", {
|
|
2630
|
+
...context,
|
|
2631
|
+
maxTimeMS: req.executionHints?.maxTimeMs ?? context.maxTimeMS,
|
|
2632
|
+
allowDiskUse: req.executionHints?.allowDiskUse ?? context.allowDiskUse
|
|
2633
|
+
}),
|
|
2634
|
+
finalize: (stages) => this._scopeJoins(stages, context)
|
|
2635
|
+
};
|
|
2636
|
+
}
|
|
2637
|
+
/** Scope every join in `pipeline` under its owning collection's policy (see `join-scope.ts`). */
|
|
2638
|
+
_scopeJoins(pipeline, context) {
|
|
2639
|
+
return scopeJoins(pipeline, {
|
|
2640
|
+
connection: this.Model.db,
|
|
2641
|
+
context,
|
|
2642
|
+
operation: context.operation,
|
|
2643
|
+
unscopedJoins: context.unscopedJoins
|
|
2644
|
+
});
|
|
2645
|
+
}
|
|
2788
2646
|
_withResilience(context, fn) {
|
|
2789
2647
|
const signal = context.signal;
|
|
2790
2648
|
const retryPolicy = context.retryPolicy;
|
|
@@ -2799,7 +2657,10 @@ var Repository = class extends RepositoryBase {
|
|
|
2799
2657
|
data,
|
|
2800
2658
|
...options
|
|
2801
2659
|
});
|
|
2802
|
-
return this._runOp("create", context, () => create(this.Model, context.data || data,
|
|
2660
|
+
return this._runOp("create", context, () => create(this.Model, context.data || data, {
|
|
2661
|
+
...options,
|
|
2662
|
+
queryOptions: this._opts("write", context)
|
|
2663
|
+
}));
|
|
2803
2664
|
}
|
|
2804
2665
|
/**
|
|
2805
2666
|
* Create multiple documents
|
|
@@ -2809,7 +2670,10 @@ var Repository = class extends RepositoryBase {
|
|
|
2809
2670
|
dataArray,
|
|
2810
2671
|
...options
|
|
2811
2672
|
});
|
|
2812
|
-
return this._runOp("createMany", context, () => createMany(this.Model, context.dataArray || dataArray,
|
|
2673
|
+
return this._runOp("createMany", context, () => createMany(this.Model, context.dataArray || dataArray, {
|
|
2674
|
+
...options,
|
|
2675
|
+
queryOptions: this._opts("write", context)
|
|
2676
|
+
}));
|
|
2813
2677
|
}
|
|
2814
2678
|
/**
|
|
2815
2679
|
* Get document by ID
|
|
@@ -2837,7 +2701,10 @@ var Repository = class extends RepositoryBase {
|
|
|
2837
2701
|
const result = await this._withResilience(context, () => getByQuery(this.Model, {
|
|
2838
2702
|
[effectiveIdField]: id,
|
|
2839
2703
|
...context.query || {}
|
|
2840
|
-
},
|
|
2704
|
+
}, {
|
|
2705
|
+
...context,
|
|
2706
|
+
queryOptions: this._opts("read", context)
|
|
2707
|
+
}));
|
|
2841
2708
|
await this._emitHook("after:getById", {
|
|
2842
2709
|
context,
|
|
2843
2710
|
result
|
|
@@ -2854,7 +2721,10 @@ var Repository = class extends RepositoryBase {
|
|
|
2854
2721
|
});
|
|
2855
2722
|
return null;
|
|
2856
2723
|
}
|
|
2857
|
-
const result = await this._withResilience(context, () => getById(this.Model, id,
|
|
2724
|
+
const result = await this._withResilience(context, () => getById(this.Model, id, {
|
|
2725
|
+
...context,
|
|
2726
|
+
queryOptions: this._opts("read", context)
|
|
2727
|
+
}));
|
|
2858
2728
|
if (!result && wantsThrow) throw createError(404, "Document not found");
|
|
2859
2729
|
await this._emitHook("after:getById", {
|
|
2860
2730
|
context,
|
|
@@ -2870,39 +2740,21 @@ var Repository = class extends RepositoryBase {
|
|
|
2870
2740
|
}
|
|
2871
2741
|
});
|
|
2872
2742
|
}
|
|
2873
|
-
/**
|
|
2874
|
-
* Batch point-read — the `$in` counterpart to {@link getById}. Fetches every
|
|
2875
|
-
* doc whose id (the repo's `idField`, default `_id`) is in `ids` in ONE query,
|
|
2876
|
-
* routed through `findAll` so tenant + soft-delete scoping and hooks apply
|
|
2877
|
-
* exactly as a single read would. Returns a Map keyed by the stringified id;
|
|
2878
|
-
* ids are de-duplicated and ids with no matching doc are simply absent (no
|
|
2879
|
-
* throw — mirrors `getById(..., { throwOnNotFound: false })`). Structurally
|
|
2880
|
-
* INVALID ids (wrong ObjectId shape, non-numeric for a Number id field, ...)
|
|
2881
|
-
* are dropped the same way — an id that cannot exist in the collection is
|
|
2882
|
-
* unambiguously a miss, and one malformed id must never poison the rest of
|
|
2883
|
-
* the batch. Lean by default (findAll semantics) — pass `{ lean: false }`
|
|
2884
|
-
* for hydrated docs.
|
|
2885
|
-
*
|
|
2886
|
-
* The N+1 killer for hosts that resolve many ids in one tick (order-line
|
|
2887
|
-
* snapshotting, availability matrices, dashboard row enrichment):
|
|
2888
|
-
*
|
|
2889
|
-
* @example
|
|
2890
|
-
* const byId = await repo.getByIds(offerIds, ctx);
|
|
2891
|
-
* const doc = byId.get(offerId); // O(1); undefined if not found
|
|
2892
|
-
*
|
|
2893
|
-
* Sizing: there is no library-imposed cap — the bounds are MongoDB's (the
|
|
2894
|
-
* `$in` array must fit the 16 MB query document; `$in` on an indexed field
|
|
2895
|
-
* is n point-lookups). Keep batches ≤ ~10k ids per call and chunk beyond
|
|
2896
|
-
* that. Don't exclude the id field via `select` — result keys derive from
|
|
2897
|
-
* it.
|
|
2898
|
-
*/
|
|
2899
2743
|
async getByIds(ids, options = {}) {
|
|
2744
|
+
const { chunkSize = DEFAULT_ID_CHUNK, concurrency = 4, preserveOrder, ...readOptions } = options;
|
|
2745
|
+
if (!Number.isInteger(concurrency) || concurrency < 1) throw new TypeError(`getByIds: concurrency must be a positive integer, got ${String(concurrency)}`);
|
|
2900
2746
|
const field = options.idField ?? this.idField;
|
|
2901
2747
|
const idType = getSchemaIdType(this.Model.schema, field);
|
|
2902
2748
|
const unique = [...new Set(ids.map((id) => String(id)))].filter((id) => isValidIdForType(id, idType));
|
|
2903
|
-
|
|
2904
|
-
const
|
|
2905
|
-
|
|
2749
|
+
const byId = /* @__PURE__ */ new Map();
|
|
2750
|
+
const chunks = [...idChunks(unique, chunkSize)];
|
|
2751
|
+
const load = async (chunk) => {
|
|
2752
|
+
const docs = await this.findAll({ [field]: { $in: chunk } }, readOptions);
|
|
2753
|
+
for (const d of docs) byId.set(String(d[field]), d);
|
|
2754
|
+
};
|
|
2755
|
+
if (readOptions.session) for (const chunk of chunks) await load(chunk);
|
|
2756
|
+
else for (let i = 0; i < chunks.length; i += concurrency) await Promise.all(chunks.slice(i, i + concurrency).map(load));
|
|
2757
|
+
return preserveOrder ? ids.map((id) => byId.get(String(id))) : byId;
|
|
2906
2758
|
}
|
|
2907
2759
|
/**
|
|
2908
2760
|
* Get single document by query
|
|
@@ -2924,7 +2776,10 @@ var Repository = class extends RepositoryBase {
|
|
|
2924
2776
|
return cachedResult;
|
|
2925
2777
|
}
|
|
2926
2778
|
const finalQuery = context.query || query;
|
|
2927
|
-
return this._runOp("getByQuery", context, () => getByQuery(this.Model, finalQuery,
|
|
2779
|
+
return this._runOp("getByQuery", context, () => getByQuery(this.Model, finalQuery, {
|
|
2780
|
+
...context,
|
|
2781
|
+
queryOptions: this._opts("read", context)
|
|
2782
|
+
}));
|
|
2928
2783
|
}
|
|
2929
2784
|
/**
|
|
2930
2785
|
* Get single document by arbitrary filter.
|
|
@@ -2953,7 +2808,10 @@ var Repository = class extends RepositoryBase {
|
|
|
2953
2808
|
}
|
|
2954
2809
|
const finalQuery = context.query || query;
|
|
2955
2810
|
try {
|
|
2956
|
-
const result = await this._withResilience(context, () => getByQuery(this.Model, finalQuery,
|
|
2811
|
+
const result = await this._withResilience(context, () => getByQuery(this.Model, finalQuery, {
|
|
2812
|
+
...context,
|
|
2813
|
+
queryOptions: this._opts("read", context)
|
|
2814
|
+
}));
|
|
2957
2815
|
await this._emitHook("after:getOne", {
|
|
2958
2816
|
context,
|
|
2959
2817
|
result
|
|
@@ -2991,13 +2849,68 @@ var Repository = class extends RepositoryBase {
|
|
|
2991
2849
|
if (options.populate || context.populate) query.populate(this._parsePopulate(context.populate || options.populate));
|
|
2992
2850
|
if (context.lean ?? options.lean ?? true) query.lean();
|
|
2993
2851
|
if (options.session) query.session(options.session);
|
|
2994
|
-
|
|
2852
|
+
applyToQuery(query, this._opts("read", context));
|
|
2995
2853
|
const limitSpec = context.limit ?? options.limit;
|
|
2996
2854
|
if (typeof limitSpec === "number" && limitSpec > 0) query.limit(limitSpec);
|
|
2997
2855
|
return await query.exec();
|
|
2998
2856
|
});
|
|
2999
2857
|
}
|
|
3000
2858
|
/**
|
|
2859
|
+
* The keyset cursor `getAll({ sort, filters, mode: 'keyset' })` would mint for `row`, under the
|
|
2860
|
+
* same policy scope, so a page resumes AFTER it. For composing keyset walks (repo-core
|
|
2861
|
+
* `mergeKeysetPages` via `keysetSource`), where the last CONSUMED row is not a page's last row.
|
|
2862
|
+
*/
|
|
2863
|
+
async keysetCursor(row, options) {
|
|
2864
|
+
if (typeof row !== "object" || row === null || !("_id" in row)) throw createError(400, "keysetCursor: the row must be a document carrying _id");
|
|
2865
|
+
const { sort, filters = {}, collation, ...rest } = options;
|
|
2866
|
+
const scoped = (await this._buildContext("getAll", {
|
|
2867
|
+
filters,
|
|
2868
|
+
sort,
|
|
2869
|
+
...rest
|
|
2870
|
+
})).filters ?? filters;
|
|
2871
|
+
const cfg = this._pagination.config;
|
|
2872
|
+
const normalized = validateKeysetSort(this._parseSort(sort), cfg.strictKeysetSortFields);
|
|
2873
|
+
return encodeCursor(Object.fromEntries(Object.entries(row)), getPrimaryField(normalized), normalized, cfg.cursorVersion, cursorScope(this.Model.collection.collectionName, scoped, collation), cfg.cursorSecret);
|
|
2874
|
+
}
|
|
2875
|
+
/**
|
|
2876
|
+
* Resumable keyset batches over `_id`: each batch is ONE bounded `find` (no long-lived server
|
|
2877
|
+
* cursor, so no cursor timeout and no CSOT lifetime budget), memory is one batch, and every
|
|
2878
|
+
* batch carries a `checkpoint`. Pass a checkpoint back as `after` to resume: rows written
|
|
2879
|
+
* behind it are not revisited, rows ahead of it are reached. A checkpoint is bound to the
|
|
2880
|
+
* collection, the post-policy filter (tenant included) and is refused elsewhere.
|
|
2881
|
+
*/
|
|
2882
|
+
async *iterate(filter = {}, options = {}) {
|
|
2883
|
+
const { after, batchSize = 500, select, ...rest } = options;
|
|
2884
|
+
if (!Number.isInteger(batchSize) || batchSize < 1) throw createError(400, `iterate: batchSize must be a positive integer, got ${String(batchSize)}`);
|
|
2885
|
+
const context = await this._buildContext("iterate", {
|
|
2886
|
+
query: filter,
|
|
2887
|
+
...rest
|
|
2888
|
+
});
|
|
2889
|
+
const scoped = context.query ?? filter;
|
|
2890
|
+
const sort = { _id: 1 };
|
|
2891
|
+
const scope = cursorScope(this.Model.collection.collectionName, scoped, void 0);
|
|
2892
|
+
const cfg = this._pagination.config;
|
|
2893
|
+
let position = after ? resolveCursorFilter(after, sort, cfg.cursorVersion, scoped, cfg.minCursorVersion, sort, scope, cfg.cursorSecret) : scoped;
|
|
2894
|
+
const opts = this._opts("read", context);
|
|
2895
|
+
for (;;) {
|
|
2896
|
+
const query = this.Model.find(position).sort(sort).limit(batchSize).lean();
|
|
2897
|
+
if (select) query.select(select);
|
|
2898
|
+
if (context.session) query.session(context.session);
|
|
2899
|
+
applyToQuery(query, opts);
|
|
2900
|
+
const docs = await this._withResilience(context, () => query.exec());
|
|
2901
|
+
if (docs.length === 0) return;
|
|
2902
|
+
const last = docs[docs.length - 1];
|
|
2903
|
+
if (last._id === void 0) throw createError(400, "iterate: the checkpoint needs _id; do not exclude it with select");
|
|
2904
|
+
const checkpoint = encodeCursor(last, "_id", sort, cfg.cursorVersion, scope, cfg.cursorSecret);
|
|
2905
|
+
yield {
|
|
2906
|
+
docs,
|
|
2907
|
+
checkpoint
|
|
2908
|
+
};
|
|
2909
|
+
if (docs.length < batchSize) return;
|
|
2910
|
+
position = resolveCursorFilter(checkpoint, sort, cfg.cursorVersion, scoped, cfg.minCursorVersion, sort, scope, cfg.cursorSecret);
|
|
2911
|
+
}
|
|
2912
|
+
}
|
|
2913
|
+
/**
|
|
3001
2914
|
* Streaming reads — async iterator over data, suitable for migrations,
|
|
3002
2915
|
* backfills, schema audits, and any once-a-quarter "touch every row"
|
|
3003
2916
|
* job. Goes through the standard `before:cursor` hook pipeline so
|
|
@@ -3038,7 +2951,9 @@ var Repository = class extends RepositoryBase {
|
|
|
3038
2951
|
if (options.batchSize) query.batchSize(options.batchSize);
|
|
3039
2952
|
if (context.lean ?? options.lean ?? true) query.lean();
|
|
3040
2953
|
if (options.session) query.session(options.session);
|
|
3041
|
-
|
|
2954
|
+
const cursorOpts = this._opts("read", context);
|
|
2955
|
+
applyToQuery(query, cursorOpts);
|
|
2956
|
+
applyCursorTimeoutMode(query, cursorOpts, this.Model.db);
|
|
3042
2957
|
const stream = query.cursor();
|
|
3043
2958
|
let yieldedCount = 0;
|
|
3044
2959
|
try {
|
|
@@ -3272,29 +3187,6 @@ var Repository = class extends RepositoryBase {
|
|
|
3272
3187
|
await closeStream();
|
|
3273
3188
|
}
|
|
3274
3189
|
}
|
|
3275
|
-
/**
|
|
3276
|
-
* Unified pagination - auto-detects offset vs keyset based on params
|
|
3277
|
-
*
|
|
3278
|
-
* Auto-detection logic:
|
|
3279
|
-
* - If params has 'cursor' or 'after' → uses keyset pagination (stream)
|
|
3280
|
-
* - If params has 'pagination' or 'page' → uses offset pagination (paginate)
|
|
3281
|
-
* - Else → defaults to offset pagination with page=1
|
|
3282
|
-
*
|
|
3283
|
-
* @example
|
|
3284
|
-
* // Offset pagination (page-based)
|
|
3285
|
-
* await repo.getAll({ page: 1, limit: 50, filters: { status: 'active' } });
|
|
3286
|
-
* await repo.getAll({ pagination: { page: 2, limit: 20 } });
|
|
3287
|
-
*
|
|
3288
|
-
* // Keyset pagination (cursor-based)
|
|
3289
|
-
* await repo.getAll({ cursor: 'eyJ2Ij...', limit: 50 });
|
|
3290
|
-
* await repo.getAll({ after: 'eyJ2Ij...', sort: { createdAt: -1 } });
|
|
3291
|
-
*
|
|
3292
|
-
* // Simple query (defaults to page 1)
|
|
3293
|
-
* await repo.getAll({ filters: { status: 'active' } });
|
|
3294
|
-
*
|
|
3295
|
-
* // Skip cache for fresh data
|
|
3296
|
-
* await repo.getAll({ filters: { status: 'active' } }, { skipCache: true });
|
|
3297
|
-
*/
|
|
3298
3190
|
async getAll(params = {}, options = {}) {
|
|
3299
3191
|
const normalizedParams = {
|
|
3300
3192
|
...params,
|
|
@@ -3343,7 +3235,12 @@ var Repository = class extends RepositoryBase {
|
|
|
3343
3235
|
lean: context.lean ?? options.lean ?? true,
|
|
3344
3236
|
session: options.session,
|
|
3345
3237
|
hint: context.hint ?? params.hint,
|
|
3346
|
-
|
|
3238
|
+
queryOptions: this._opts("read", {
|
|
3239
|
+
...context,
|
|
3240
|
+
maxTimeMS: context.maxTimeMS ?? params.maxTimeMS,
|
|
3241
|
+
readPreference: context.readPreference ?? options.readPreference ?? params.readPreference,
|
|
3242
|
+
session: options.session
|
|
3243
|
+
}),
|
|
3347
3244
|
countLimit: context.countLimit ?? params.countLimit,
|
|
3348
3245
|
readPreference: context.readPreference ?? options.readPreference ?? params.readPreference,
|
|
3349
3246
|
collation: context.collation ?? params.collation
|
|
@@ -3425,7 +3322,10 @@ var Repository = class extends RepositoryBase {
|
|
|
3425
3322
|
query,
|
|
3426
3323
|
...options
|
|
3427
3324
|
});
|
|
3428
|
-
return this._runOp("count", context, () => count(this.Model, context.query || query,
|
|
3325
|
+
return this._runOp("count", context, () => count(this.Model, context.query || query, {
|
|
3326
|
+
session: options.session,
|
|
3327
|
+
queryOptions: this._opts("read", context)
|
|
3328
|
+
}));
|
|
3429
3329
|
}
|
|
3430
3330
|
/**
|
|
3431
3331
|
* Check if document exists
|
|
@@ -3436,7 +3336,10 @@ var Repository = class extends RepositoryBase {
|
|
|
3436
3336
|
query,
|
|
3437
3337
|
...options
|
|
3438
3338
|
});
|
|
3439
|
-
return this._runOp("exists", context, () => exists(this.Model, context.query || query,
|
|
3339
|
+
return this._runOp("exists", context, () => exists(this.Model, context.query || query, {
|
|
3340
|
+
session: options.session,
|
|
3341
|
+
queryOptions: this._opts("read", context)
|
|
3342
|
+
}));
|
|
3440
3343
|
}
|
|
3441
3344
|
/**
|
|
3442
3345
|
* Update a document by id and return the post-update doc.
|
|
@@ -3602,7 +3505,8 @@ var Repository = class extends RepositoryBase {
|
|
|
3602
3505
|
casUpdate,
|
|
3603
3506
|
{
|
|
3604
3507
|
...context,
|
|
3605
|
-
throwOnNotFound: false
|
|
3508
|
+
throwOnNotFound: false,
|
|
3509
|
+
queryOptions: this._opts("findAndModify", context)
|
|
3606
3510
|
}
|
|
3607
3511
|
));
|
|
3608
3512
|
if (!result) {
|
|
@@ -3626,8 +3530,14 @@ var Repository = class extends RepositoryBase {
|
|
|
3626
3530
|
} else if (effectiveIdField !== "_id") result = await this._withResilience(context, () => updateByQuery(this.Model, {
|
|
3627
3531
|
[effectiveIdField]: id,
|
|
3628
3532
|
...context.query || {}
|
|
3629
|
-
}, context.data || data,
|
|
3630
|
-
|
|
3533
|
+
}, context.data || data, {
|
|
3534
|
+
...context,
|
|
3535
|
+
queryOptions: this._opts("findAndModify", context)
|
|
3536
|
+
}));
|
|
3537
|
+
else result = await this._withResilience(context, () => update(this.Model, id, context.data || data, {
|
|
3538
|
+
...context,
|
|
3539
|
+
queryOptions: this._opts("findAndModify", context)
|
|
3540
|
+
}));
|
|
3631
3541
|
if (!result && wantsThrow) throw createError(404, "Document not found");
|
|
3632
3542
|
await this._emitHook("after:update", {
|
|
3633
3543
|
context,
|
|
@@ -3687,7 +3597,8 @@ var Repository = class extends RepositoryBase {
|
|
|
3687
3597
|
sort: options.sort,
|
|
3688
3598
|
returnDocument: options.returnDocument ?? "after",
|
|
3689
3599
|
upsert: options.upsert ?? false,
|
|
3690
|
-
session: options.session
|
|
3600
|
+
session: options.session,
|
|
3601
|
+
queryOptions: this._opts("findAndModify", context)
|
|
3691
3602
|
});
|
|
3692
3603
|
});
|
|
3693
3604
|
}
|
|
@@ -3862,6 +3773,11 @@ var Repository = class extends RepositoryBase {
|
|
|
3862
3773
|
[effectiveIdField]: id,
|
|
3863
3774
|
[stateField]: fromSpec
|
|
3864
3775
|
};
|
|
3776
|
+
const seeded = options.target;
|
|
3777
|
+
if (seeded !== void 0) {
|
|
3778
|
+
if (String(seeded[effectiveIdField]) !== String(id)) throw new Error(`[claim] options.target is ${effectiveIdField}=${String(seeded[effectiveIdField])}, not the claimed ${String(id)}`);
|
|
3779
|
+
for (const key of [this.versionField, "updatedAt"]) if (seeded[key] !== void 0 && !(key in filter)) filter[key] = seeded[key];
|
|
3780
|
+
}
|
|
3865
3781
|
assertNoMixedPatchShape("claim", patch);
|
|
3866
3782
|
const patchKeys = Object.keys(patch);
|
|
3867
3783
|
const operatorPatchKeys = patchKeys.filter((k) => k.startsWith("$"));
|
|
@@ -3911,7 +3827,8 @@ var Repository = class extends RepositoryBase {
|
|
|
3911
3827
|
return await findOneAndUpdate(this.Model, finalQuery, finalUpdate, {
|
|
3912
3828
|
returnDocument: "after",
|
|
3913
3829
|
upsert: options.upsert ?? false,
|
|
3914
|
-
session: options.session
|
|
3830
|
+
session: options.session,
|
|
3831
|
+
queryOptions: this._opts("findAndModify", context)
|
|
3915
3832
|
});
|
|
3916
3833
|
});
|
|
3917
3834
|
}
|
|
@@ -4144,7 +4061,8 @@ var Repository = class extends RepositoryBase {
|
|
|
4144
4061
|
return await findOneAndUpdate(this.Model, finalQuery, finalUpdate, {
|
|
4145
4062
|
returnDocument: "after",
|
|
4146
4063
|
upsert: false,
|
|
4147
|
-
session: options.session
|
|
4064
|
+
session: options.session,
|
|
4065
|
+
queryOptions: this._opts("findAndModify", context)
|
|
4148
4066
|
});
|
|
4149
4067
|
});
|
|
4150
4068
|
}
|
|
@@ -4206,9 +4124,13 @@ var Repository = class extends RepositoryBase {
|
|
|
4206
4124
|
[effectiveIdField]: id,
|
|
4207
4125
|
...context.query || {}
|
|
4208
4126
|
} : void 0;
|
|
4209
|
-
const result = await this._withResilience(context, () => deleteQuery ? deleteByQuery(this.Model, deleteQuery, {
|
|
4127
|
+
const result = await this._withResilience(context, () => deleteQuery ? deleteByQuery(this.Model, deleteQuery, {
|
|
4210
4128
|
session: options.session,
|
|
4211
|
-
|
|
4129
|
+
queryOptions: this._opts("findAndModify", context)
|
|
4130
|
+
}) : deleteById(this.Model, id, {
|
|
4131
|
+
session: options.session,
|
|
4132
|
+
query: context.query,
|
|
4133
|
+
queryOptions: this._opts("findAndModify", context)
|
|
4212
4134
|
}));
|
|
4213
4135
|
if (!result && wantsThrow) throw createError(404, "Document not found");
|
|
4214
4136
|
await this._emitHook("after:delete", {
|
|
@@ -4256,11 +4178,13 @@ var Repository = class extends RepositoryBase {
|
|
|
4256
4178
|
if (!finalQuery || Object.keys(finalQuery).length === 0) throw createError(400, "updateMany requires a non-empty query filter. Pass an explicit filter to prevent accidental mass updates.");
|
|
4257
4179
|
if (Array.isArray(normalizedData) && options.updatePipeline !== true) throw createError(400, "Update pipelines (array updates) are disabled by default; pass `{ updatePipeline: true }` to explicitly allow pipeline-style updates.");
|
|
4258
4180
|
const finalData = context.data || normalizedData;
|
|
4259
|
-
|
|
4181
|
+
const updateQuery = this.Model.updateMany(finalQuery, finalData, {
|
|
4260
4182
|
runValidators: true,
|
|
4261
4183
|
session: options.session,
|
|
4262
4184
|
...options.updatePipeline !== void 0 ? { updatePipeline: options.updatePipeline } : {}
|
|
4263
|
-
})
|
|
4185
|
+
});
|
|
4186
|
+
applyToQuery(updateQuery, this._opts("write", context));
|
|
4187
|
+
return await updateQuery.exec();
|
|
4264
4188
|
});
|
|
4265
4189
|
}
|
|
4266
4190
|
/**
|
|
@@ -4302,7 +4226,11 @@ var Repository = class extends RepositoryBase {
|
|
|
4302
4226
|
}
|
|
4303
4227
|
const finalQuery = context.query || filter;
|
|
4304
4228
|
if (!finalQuery || Object.keys(finalQuery).length === 0) throw createError(400, "deleteMany requires a non-empty query filter after policy hooks.");
|
|
4305
|
-
const result = await this._withResilience(context, () =>
|
|
4229
|
+
const result = await this._withResilience(context, () => (() => {
|
|
4230
|
+
const q = this.Model.deleteMany(finalQuery, { session: options.session });
|
|
4231
|
+
applyToQuery(q, this._opts("write", context));
|
|
4232
|
+
return q.exec();
|
|
4233
|
+
})());
|
|
4306
4234
|
await this._emitHook("after:deleteMany", {
|
|
4307
4235
|
context,
|
|
4308
4236
|
result
|
|
@@ -4365,6 +4293,70 @@ var Repository = class extends RepositoryBase {
|
|
|
4365
4293
|
return runChunkedArchive(options, sink, port);
|
|
4366
4294
|
}
|
|
4367
4295
|
/**
|
|
4296
|
+
* Many keyed upserts in ONE command with an exact per-row outcome (repo-core `bulkUpsert` IR).
|
|
4297
|
+
* Sub-ops pass through the `before:bulkUpsert` policy hooks (tenant, soft-delete) like bulkWrite
|
|
4298
|
+
* sub-ops. Refused unless a unique index is pinned by the key plus the policy scope. Needs
|
|
4299
|
+
* MongoDB 8.0 (client bulkWrite with verbose results).
|
|
4300
|
+
*/
|
|
4301
|
+
async bulkUpsert(rows, options) {
|
|
4302
|
+
const invalid = bulkUpsertOptionsError(options);
|
|
4303
|
+
if (invalid) throw createError(400, `[mongokit] bulkUpsert: ${invalid}`, { code: BULK_UPSERT_ERROR_CODES.INVALID_OPTIONS });
|
|
4304
|
+
const { key, set, inc = [], setOnInsert = [], ordered = false, ...rest } = options;
|
|
4305
|
+
const { planned, refused } = planUpserts(rows, key, {
|
|
4306
|
+
set,
|
|
4307
|
+
inc,
|
|
4308
|
+
setOnInsert
|
|
4309
|
+
});
|
|
4310
|
+
const operations = planned.map((p) => ({ updateOne: {
|
|
4311
|
+
filter: p.filter,
|
|
4312
|
+
update: p.update,
|
|
4313
|
+
upsert: true
|
|
4314
|
+
} }));
|
|
4315
|
+
const context = await this._buildContext("bulkUpsert", {
|
|
4316
|
+
...rest,
|
|
4317
|
+
operations
|
|
4318
|
+
});
|
|
4319
|
+
const scoped = context.operations ?? operations;
|
|
4320
|
+
const filterFields = Object.keys(scoped[0]?.updateOne.filter ?? Object.fromEntries(key.map((k) => [k, 1])));
|
|
4321
|
+
if (!keyIsUnique(this.Model, filterFields)) throw createError(400, `[mongokit] bulkUpsert on '${this.model}': no unique index is pinned by (${filterFields.join(", ")}), so one key could match several rows`, {
|
|
4322
|
+
code: BULK_UPSERT_ERROR_CODES.KEY_NOT_UNIQUE,
|
|
4323
|
+
meta: {
|
|
4324
|
+
key,
|
|
4325
|
+
filterFields
|
|
4326
|
+
}
|
|
4327
|
+
});
|
|
4328
|
+
planned.forEach((p, i) => {
|
|
4329
|
+
const filter = scoped[i]?.updateOne.filter ?? p.filter;
|
|
4330
|
+
for (const field of Object.keys(filter)) {
|
|
4331
|
+
if (key.includes(field)) continue;
|
|
4332
|
+
for (const fields of Object.values(p.update)) {
|
|
4333
|
+
if (!(field in fields)) continue;
|
|
4334
|
+
if (String(fields[field]) !== String(filter[field])) throw createError(400, `[mongokit] bulkUpsert: '${field}' in row ${p.index} does not match the resolved tenant scope`, {
|
|
4335
|
+
code: BULK_UPSERT_ERROR_CODES.INVALID_OPTIONS,
|
|
4336
|
+
meta: {
|
|
4337
|
+
field,
|
|
4338
|
+
index: p.index
|
|
4339
|
+
}
|
|
4340
|
+
});
|
|
4341
|
+
delete fields[field];
|
|
4342
|
+
}
|
|
4343
|
+
}
|
|
4344
|
+
});
|
|
4345
|
+
return this._runOp("bulkUpsert", context, async () => {
|
|
4346
|
+
const writeOpts = this._opts("write", context);
|
|
4347
|
+
const sent = await executeUpserts(this.Model, planned.map((p, i) => ({
|
|
4348
|
+
...p,
|
|
4349
|
+
filter: scoped[i]?.updateOne.filter ?? p.filter
|
|
4350
|
+
})), {
|
|
4351
|
+
ordered,
|
|
4352
|
+
session: context.session,
|
|
4353
|
+
writeConcern: writeOpts.writeConcern,
|
|
4354
|
+
timeoutMS: writeOpts.maxTimeMS
|
|
4355
|
+
});
|
|
4356
|
+
return summarizeBulkUpsert([...refused, ...sent].sort((a, b) => a.index - b.index));
|
|
4357
|
+
});
|
|
4358
|
+
}
|
|
4359
|
+
/**
|
|
4368
4360
|
* Kit-native aggregation pipeline — takes a MongoDB stage array and
|
|
4369
4361
|
* returns the raw pipeline output. Use this for `$lookup`, `$unwind`,
|
|
4370
4362
|
* `$facet`, `$graphLookup`, and any other mongo-specific power
|
|
@@ -4398,15 +4390,10 @@ var Repository = class extends RepositoryBase {
|
|
|
4398
4390
|
const maxStages = options.maxPipelineStages;
|
|
4399
4391
|
if (maxStages && pipeline.length > maxStages) throw createError(400, `Aggregation pipeline exceeds maximum allowed stages (${pipeline.length} > ${maxStages})`);
|
|
4400
4392
|
return this._runOp("aggregatePipeline", context, async () => {
|
|
4401
|
-
const
|
|
4402
|
-
|
|
4403
|
-
const aggregation = this.Model.aggregate(finalPipeline);
|
|
4393
|
+
const scoped = context.query && Object.keys(context.query).length > 0 ? [{ $match: context.query }, ...pipeline] : pipeline;
|
|
4394
|
+
const aggregation = this.Model.aggregate(this._scopeJoins(scoped, context));
|
|
4404
4395
|
if (options.session) aggregation.session(options.session);
|
|
4405
|
-
|
|
4406
|
-
if (options.readPreference) aggregation.read(options.readPreference);
|
|
4407
|
-
if (options.maxTimeMS) aggregation.option({ maxTimeMS: options.maxTimeMS });
|
|
4408
|
-
if (options.comment) aggregation.option({ comment: options.comment });
|
|
4409
|
-
if (options.readConcern) aggregation.option({ readConcern: options.readConcern });
|
|
4396
|
+
applyToAggregate(aggregation, this._opts("aggregate", context));
|
|
4410
4397
|
if (options.collation) aggregation.collation(options.collation);
|
|
4411
4398
|
return await aggregation.exec();
|
|
4412
4399
|
});
|
|
@@ -4429,7 +4416,8 @@ var Repository = class extends RepositoryBase {
|
|
|
4429
4416
|
if (context.filters && Object.keys(context.filters).length > 0) finalPipeline.unshift({ $match: context.filters });
|
|
4430
4417
|
const aggOptions = {
|
|
4431
4418
|
...context,
|
|
4432
|
-
pipeline: finalPipeline
|
|
4419
|
+
pipeline: this._scopeJoins(finalPipeline, context),
|
|
4420
|
+
queryOptions: this._opts("aggregate", context)
|
|
4433
4421
|
};
|
|
4434
4422
|
return this._runOp("aggregatePipelinePaginate", context, () => this._pagination.aggregatePaginate(aggOptions));
|
|
4435
4423
|
}
|
|
@@ -4460,7 +4448,7 @@ var Repository = class extends RepositoryBase {
|
|
|
4460
4448
|
aggRequest: req,
|
|
4461
4449
|
...options
|
|
4462
4450
|
});
|
|
4463
|
-
const cached = this._cachedValue(context);
|
|
4451
|
+
const cached = options.explain ? void 0 : this._cachedValue(context);
|
|
4464
4452
|
if (cached !== void 0) {
|
|
4465
4453
|
await this._emitHook("after:aggregate", {
|
|
4466
4454
|
context,
|
|
@@ -4471,7 +4459,14 @@ var Repository = class extends RepositoryBase {
|
|
|
4471
4459
|
}
|
|
4472
4460
|
return this._runOp("aggregate", context, async () => {
|
|
4473
4461
|
const finalReq = this._injectPolicyScopeIntoAgg(req, context);
|
|
4474
|
-
|
|
4462
|
+
const run = this._aggRun(finalReq, context);
|
|
4463
|
+
const rows = await executeAgg(this.Model, finalReq, run);
|
|
4464
|
+
if (!options.explain) return { rows };
|
|
4465
|
+
const { pipeline } = buildAggPipeline(finalReq, this.Model.schema);
|
|
4466
|
+
return {
|
|
4467
|
+
rows,
|
|
4468
|
+
plan: await prepareAgg(this.Model, pipeline, finalReq, run).explain(options.explain)
|
|
4469
|
+
};
|
|
4475
4470
|
});
|
|
4476
4471
|
}
|
|
4477
4472
|
/**
|
|
@@ -4518,7 +4513,7 @@ var Repository = class extends RepositoryBase {
|
|
|
4518
4513
|
...options
|
|
4519
4514
|
});
|
|
4520
4515
|
const limit = Math.max(1, Math.min(req.limit ?? 20, 1e3));
|
|
4521
|
-
|
|
4516
|
+
context.session;
|
|
4522
4517
|
const useKeyset = isKeysetMode(req);
|
|
4523
4518
|
const cachedEnvelope = this._cachedValue(context);
|
|
4524
4519
|
if (cachedEnvelope !== void 0) {
|
|
@@ -4531,8 +4526,12 @@ var Repository = class extends RepositoryBase {
|
|
|
4531
4526
|
}
|
|
4532
4527
|
return this._composeMiddleware("aggregatePaginate", context, async () => {
|
|
4533
4528
|
try {
|
|
4534
|
-
const
|
|
4535
|
-
const
|
|
4529
|
+
const scopedReq = this._injectPolicyScopeIntoAgg(req, context);
|
|
4530
|
+
const finalReq = scopedReq.sort ? {
|
|
4531
|
+
...scopedReq,
|
|
4532
|
+
sort: withGroupTiebreak(scopedReq.sort, normalizeGroupBy(scopedReq.groupBy))
|
|
4533
|
+
} : scopedReq;
|
|
4534
|
+
const result = await this._executeAggregatePaginate(finalReq, useKeyset, limit, this._aggRun(finalReq, context));
|
|
4536
4535
|
await this._emitHook("after:aggregatePaginate", {
|
|
4537
4536
|
context,
|
|
4538
4537
|
result
|
|
@@ -4556,7 +4555,7 @@ var Repository = class extends RepositoryBase {
|
|
|
4556
4555
|
* built by the outer wrapper matches the request the executor
|
|
4557
4556
|
* sees — no cross-tenant cache poisoning.
|
|
4558
4557
|
*/
|
|
4559
|
-
async _executeAggregatePaginate(finalReq, useKeyset, limit,
|
|
4558
|
+
async _executeAggregatePaginate(finalReq, useKeyset, limit, run) {
|
|
4560
4559
|
if (useKeyset) {
|
|
4561
4560
|
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");
|
|
4562
4561
|
const cursor = finalReq.after ? decodeAggCursor$1(finalReq.after, this._pagination.config.cursorSecret) : void 0;
|
|
@@ -4567,10 +4566,7 @@ var Repository = class extends RepositoryBase {
|
|
|
4567
4566
|
if (keysetMatch) tailStages.push(keysetMatch);
|
|
4568
4567
|
tailStages.push({ $sort: finalReq.sort });
|
|
4569
4568
|
tailStages.push({ $limit: limit + 1 });
|
|
4570
|
-
const
|
|
4571
|
-
if (session) aggregation.session(session);
|
|
4572
|
-
applyExecutionHints(aggregation, finalReq.executionHints);
|
|
4573
|
-
const peeked = await aggregation.exec();
|
|
4569
|
+
const peeked = await runAgg(this.Model, [...headStages, ...tailStages], finalReq, run);
|
|
4574
4570
|
const hasMore = peeked.length > limit;
|
|
4575
4571
|
const data = hasMore ? peeked.slice(0, limit) : peeked;
|
|
4576
4572
|
return {
|
|
@@ -4584,12 +4580,13 @@ var Repository = class extends RepositoryBase {
|
|
|
4584
4580
|
const page = Math.max(1, finalReq.page ?? 1);
|
|
4585
4581
|
const countStrategy = downgradeCappedCount(finalReq.countStrategy ?? this._pagination.config.defaultCountStrategy, "aggregatePaginateIr");
|
|
4586
4582
|
const offset = (page - 1) * limit;
|
|
4583
|
+
assertOffsetWithinCap(offset, this._pagination.config.maxOffset);
|
|
4587
4584
|
if (countStrategy === "none") {
|
|
4588
4585
|
const peek = await executeAgg(this.Model, {
|
|
4589
4586
|
...finalReq,
|
|
4590
4587
|
limit: limit + 1,
|
|
4591
4588
|
offset
|
|
4592
|
-
},
|
|
4589
|
+
}, run);
|
|
4593
4590
|
const hasNext = peek.length > limit;
|
|
4594
4591
|
return {
|
|
4595
4592
|
method: "offset",
|
|
@@ -4606,7 +4603,7 @@ var Repository = class extends RepositoryBase {
|
|
|
4606
4603
|
...finalReq,
|
|
4607
4604
|
limit,
|
|
4608
4605
|
offset
|
|
4609
|
-
},
|
|
4606
|
+
}, run), countAggGroups(this.Model, finalReq, run)]);
|
|
4610
4607
|
return {
|
|
4611
4608
|
method: "offset",
|
|
4612
4609
|
data,
|
|
@@ -4664,10 +4661,9 @@ var Repository = class extends RepositoryBase {
|
|
|
4664
4661
|
});
|
|
4665
4662
|
return this._runOp("distinct", context, () => {
|
|
4666
4663
|
const finalQuery = context.query || query;
|
|
4667
|
-
const readPreference = context.readPreference ?? options.readPreference;
|
|
4668
4664
|
return distinct(this.Model, field, finalQuery, {
|
|
4669
4665
|
session: options.session,
|
|
4670
|
-
|
|
4666
|
+
queryOptions: this._opts("read", context)
|
|
4671
4667
|
});
|
|
4672
4668
|
});
|
|
4673
4669
|
}
|
|
@@ -4705,24 +4701,24 @@ var Repository = class extends RepositoryBase {
|
|
|
4705
4701
|
const filters = context.filters ?? options.filters;
|
|
4706
4702
|
const sort = context.sort ?? options.sort;
|
|
4707
4703
|
const limit = context.limit ?? options.limit ?? this._pagination.config.defaultLimit ?? 20;
|
|
4708
|
-
const readPref = context.readPreference ?? options.readPreference;
|
|
4709
4704
|
const session = context.session ?? options.session;
|
|
4710
4705
|
const collation = context.collation ?? options.collation;
|
|
4711
4706
|
const after = context.after ?? options.after;
|
|
4712
4707
|
const pageFromContext = context.page ?? options.page;
|
|
4713
4708
|
const isKeyset = !!after || !pageFromContext && !!sort;
|
|
4714
|
-
const countStrategy =
|
|
4709
|
+
const countStrategy = context.countStrategy ?? options.countStrategy ?? this._pagination.config.defaultCountStrategy;
|
|
4715
4710
|
const projection = buildLookupProjection(context.select ?? options.select, lookups);
|
|
4716
4711
|
const appendLookupStages$2 = (pipeline) => appendLookupStages(pipeline, lookups, projection, isKeyset && sort ? this._parseSort(sort) : void 0);
|
|
4717
4712
|
if (isKeyset && sort) {
|
|
4718
4713
|
const parsedSort = this._parseSort(sort);
|
|
4719
|
-
const { validateKeysetSort } = await import("./PaginationEngine-
|
|
4720
|
-
const { encodeCursor, resolveCursorFilter } = await import("./PaginationEngine-
|
|
4721
|
-
const { getPrimaryField } = await import("./PaginationEngine-
|
|
4714
|
+
const { validateKeysetSort } = await import("./PaginationEngine-CfT2Cxkj.mjs").then((n) => n.r);
|
|
4715
|
+
const { encodeCursor, resolveCursorFilter } = await import("./PaginationEngine-CfT2Cxkj.mjs").then((n) => n.a);
|
|
4716
|
+
const { getPrimaryField } = await import("./PaginationEngine-CfT2Cxkj.mjs").then((n) => n.r);
|
|
4722
4717
|
const normalizedSort = validateKeysetSort(parsedSort, this._pagination.config.strictKeysetSortFields);
|
|
4723
4718
|
const cursorVersion = this._pagination.config.cursorVersion ?? 1;
|
|
4724
4719
|
const minCursorVersion = this._pagination.config.minCursorVersion ?? 1;
|
|
4725
|
-
const
|
|
4720
|
+
const scope = cursorScope(this.Model.collection.collectionName, filters || {}, collation);
|
|
4721
|
+
const matchFilters = after ? resolveCursorFilter(after, normalizedSort, cursorVersion, { ...filters || {} }, minCursorVersion, normalizedSort, scope, this._pagination.config.cursorSecret) : { ...filters || {} };
|
|
4726
4722
|
if (projection) {
|
|
4727
4723
|
if (Object.values(projection).some((v) => v === 1)) {
|
|
4728
4724
|
for (const sortField of Object.keys(normalizedSort)) if (!(sortField in projection)) projection[sortField] = 1;
|
|
@@ -4733,9 +4729,9 @@ var Repository = class extends RepositoryBase {
|
|
|
4733
4729
|
pipeline.push({ $sort: normalizedSort });
|
|
4734
4730
|
pipeline.push({ $limit: limit + 1 });
|
|
4735
4731
|
appendLookupStages$2(pipeline);
|
|
4736
|
-
const aggregation = this.Model.aggregate(pipeline).session(session || null);
|
|
4732
|
+
const aggregation = this.Model.aggregate(this._scopeJoins(pipeline, context)).session(session || null);
|
|
4737
4733
|
if (collation) aggregation.collation(collation);
|
|
4738
|
-
|
|
4734
|
+
applyToAggregate(aggregation, this._opts("aggregate", context));
|
|
4739
4735
|
const data = await aggregation;
|
|
4740
4736
|
const hasMore = data.length > limit;
|
|
4741
4737
|
if (hasMore) data.pop();
|
|
@@ -4745,7 +4741,7 @@ var Repository = class extends RepositoryBase {
|
|
|
4745
4741
|
data,
|
|
4746
4742
|
limit,
|
|
4747
4743
|
hasMore,
|
|
4748
|
-
next: hasMore && data.length > 0 ? encodeCursor(data[data.length - 1], primaryField, normalizedSort, cursorVersion, this._pagination.config.cursorSecret) : null
|
|
4744
|
+
next: hasMore && data.length > 0 ? encodeCursor(data[data.length - 1], primaryField, normalizedSort, cursorVersion, scope, this._pagination.config.cursorSecret) : null
|
|
4749
4745
|
};
|
|
4750
4746
|
await this._emitHook("after:lookupPopulate", {
|
|
4751
4747
|
context,
|
|
@@ -4755,61 +4751,69 @@ var Repository = class extends RepositoryBase {
|
|
|
4755
4751
|
}
|
|
4756
4752
|
const page = pageFromContext ?? 1;
|
|
4757
4753
|
const skip = (page - 1) * limit;
|
|
4758
|
-
|
|
4759
|
-
const
|
|
4760
|
-
|
|
4761
|
-
if (sort) dataPipeline.push({ $sort: this._parseSort(sort) });
|
|
4762
|
-
|
|
4763
|
-
|
|
4764
|
-
|
|
4765
|
-
const aggregation = this.Model.aggregate(
|
|
4754
|
+
assertOffsetWithinCap(skip, this._pagination.config.maxOffset);
|
|
4755
|
+
const match = filters && Object.keys(filters).length > 0 ? [{ $match: filters }] : [];
|
|
4756
|
+
const dataPipeline = [...match];
|
|
4757
|
+
if (sort) dataPipeline.push({ $sort: withIdTiebreak(this._parseSort(sort)) });
|
|
4758
|
+
dataPipeline.push({ $skip: skip }, { $limit: limit + 1 });
|
|
4759
|
+
appendLookupStages$2(dataPipeline);
|
|
4760
|
+
const run = async (stages) => {
|
|
4761
|
+
const aggregation = this.Model.aggregate(this._scopeJoins(stages, context)).session(session || null);
|
|
4766
4762
|
if (collation) aggregation.collation(collation);
|
|
4767
|
-
|
|
4768
|
-
|
|
4769
|
-
|
|
4770
|
-
|
|
4771
|
-
|
|
4772
|
-
|
|
4773
|
-
|
|
4774
|
-
|
|
4775
|
-
limit,
|
|
4763
|
+
applyToAggregate(aggregation, this._opts("aggregate", context));
|
|
4764
|
+
return aggregation.exec();
|
|
4765
|
+
};
|
|
4766
|
+
const ceiling = context.countLimit ?? options.countLimit ?? this._pagination.config.defaultCountLimit;
|
|
4767
|
+
const bounded = countStrategy === "capped" || countStrategy === "cached";
|
|
4768
|
+
const countOnce = async () => {
|
|
4769
|
+
return (await run([
|
|
4770
|
+
...match,
|
|
4771
|
+
...bounded ? [{ $limit: ceiling }] : [],
|
|
4772
|
+
{ $count: "total" }
|
|
4773
|
+
]))[0]?.total ?? 0;
|
|
4774
|
+
};
|
|
4775
|
+
const runCount = async () => {
|
|
4776
|
+
if (countStrategy === "none") return {
|
|
4776
4777
|
total: 0,
|
|
4777
|
-
|
|
4778
|
-
|
|
4779
|
-
|
|
4778
|
+
totalIsEstimate: true,
|
|
4779
|
+
countedAt: null
|
|
4780
|
+
};
|
|
4781
|
+
if (countStrategy === "cached" && !session) {
|
|
4782
|
+
const key = cursorScope(this.Model.collection.collectionName, [
|
|
4783
|
+
filters ?? {},
|
|
4784
|
+
ceiling,
|
|
4785
|
+
"lookupPopulate"
|
|
4786
|
+
], collation);
|
|
4787
|
+
return {
|
|
4788
|
+
...await this._pagination._countMemo().get({
|
|
4789
|
+
key,
|
|
4790
|
+
run: countOnce
|
|
4791
|
+
}),
|
|
4792
|
+
totalIsEstimate: true
|
|
4793
|
+
};
|
|
4794
|
+
}
|
|
4795
|
+
const total = await countOnce();
|
|
4796
|
+
return {
|
|
4797
|
+
total,
|
|
4798
|
+
totalIsEstimate: bounded && total >= ceiling,
|
|
4799
|
+
countedAt: /* @__PURE__ */ new Date()
|
|
4780
4800
|
};
|
|
4781
|
-
await this._emitHook("after:lookupPopulate", {
|
|
4782
|
-
context,
|
|
4783
|
-
result
|
|
4784
|
-
});
|
|
4785
|
-
return result;
|
|
4786
|
-
}
|
|
4787
|
-
dataPipeline.push({ $skip: skip }, { $limit: limit });
|
|
4788
|
-
appendLookupStages$2(dataPipeline);
|
|
4789
|
-
const countPipeline = [];
|
|
4790
|
-
if (filters && Object.keys(filters).length > 0) countPipeline.push({ $match: filters });
|
|
4791
|
-
countPipeline.push({ $count: "total" });
|
|
4792
|
-
const pipeline = [{ $facet: {
|
|
4793
|
-
metadata: countPipeline,
|
|
4794
|
-
data: dataPipeline
|
|
4795
|
-
} }];
|
|
4796
|
-
const aggregation = this.Model.aggregate(pipeline).session(session || null);
|
|
4797
|
-
if (collation) aggregation.collation(collation);
|
|
4798
|
-
if (readPref) aggregation.read(readPref);
|
|
4799
|
-
const facetResult = (await aggregation)[0] || {
|
|
4800
|
-
metadata: [],
|
|
4801
|
-
data: []
|
|
4802
4801
|
};
|
|
4803
|
-
const
|
|
4802
|
+
const [rows, counted] = session ? [await run(dataPipeline), await runCount()] : await Promise.all([run(dataPipeline), runCount()]);
|
|
4803
|
+
const data = rows;
|
|
4804
|
+
const hasNext = data.length > limit;
|
|
4805
|
+
if (hasNext) data.pop();
|
|
4804
4806
|
const result = {
|
|
4805
4807
|
method: "offset",
|
|
4806
|
-
data
|
|
4808
|
+
data,
|
|
4807
4809
|
page,
|
|
4808
4810
|
limit,
|
|
4809
|
-
total,
|
|
4810
|
-
pages: calculateTotalPages(total, limit),
|
|
4811
|
-
hasNext
|
|
4812
|
-
hasPrev: page > 1
|
|
4811
|
+
total: counted.total,
|
|
4812
|
+
pages: countStrategy === "none" ? 0 : calculateTotalPages(counted.total, limit),
|
|
4813
|
+
hasNext,
|
|
4814
|
+
hasPrev: page > 1,
|
|
4815
|
+
totalIsEstimate: counted.totalIsEstimate,
|
|
4816
|
+
countedAt: counted.countedAt
|
|
4813
4817
|
};
|
|
4814
4818
|
await this._emitHook("after:lookupPopulate", {
|
|
4815
4819
|
context,
|
|
@@ -4945,11 +4949,51 @@ var Repository = class extends RepositoryBase {
|
|
|
4945
4949
|
async _buildContext(operation, options) {
|
|
4946
4950
|
throwIfAborted(options.signal);
|
|
4947
4951
|
const normalized = this._normalizeFilterSlots(options);
|
|
4948
|
-
const base = await super._buildContext(operation,
|
|
4952
|
+
const base = await super._buildContext(operation, {
|
|
4953
|
+
...normalized,
|
|
4954
|
+
...this._sharedReadSeams(operation, normalized)
|
|
4955
|
+
});
|
|
4949
4956
|
this._guardDistribution(operation, base);
|
|
4950
4957
|
return base;
|
|
4951
4958
|
}
|
|
4952
4959
|
/**
|
|
4960
|
+
* `memo` and (for an id-addressed op) `loadTarget` — attached before the before-hooks
|
|
4961
|
+
* run so every hook of one operation shares one read. A caller that has just read the
|
|
4962
|
+
* document in the same session passes it as `target`, and nothing is read at all.
|
|
4963
|
+
*/
|
|
4964
|
+
_sharedReadSeams(operation, options) {
|
|
4965
|
+
const store = /* @__PURE__ */ new Map();
|
|
4966
|
+
const memo = (key, load) => {
|
|
4967
|
+
let pending = store.get(key);
|
|
4968
|
+
if (!pending) {
|
|
4969
|
+
pending = Promise.resolve().then(load);
|
|
4970
|
+
store.set(key, pending);
|
|
4971
|
+
pending.catch(() => store.delete(key));
|
|
4972
|
+
}
|
|
4973
|
+
return pending;
|
|
4974
|
+
};
|
|
4975
|
+
const id = options.id;
|
|
4976
|
+
if (!OP_REGISTRY[operation]?.hasIdContext || id === void 0) return { memo };
|
|
4977
|
+
const seeded = options.target;
|
|
4978
|
+
const idField = options.idField ?? this.idField;
|
|
4979
|
+
const session = options.session;
|
|
4980
|
+
const supplied = options.query ?? {};
|
|
4981
|
+
const Model = this.Model;
|
|
4982
|
+
function loadTarget() {
|
|
4983
|
+
if (!this || typeof this !== "object") throw new Error("context.loadTarget() must be called as a method (ctx.loadTarget()) — it reads the operation tenant scope");
|
|
4984
|
+
const query = this.query ?? {};
|
|
4985
|
+
const policyScope = Object.fromEntries(Object.entries(query).filter(([k]) => !(k in supplied)));
|
|
4986
|
+
return memo("mongokit:target", async () => seeded !== void 0 ? seeded : await Model.findOne({
|
|
4987
|
+
...policyScope,
|
|
4988
|
+
[idField]: id
|
|
4989
|
+
}, null, { session }).lean());
|
|
4990
|
+
}
|
|
4991
|
+
return {
|
|
4992
|
+
memo,
|
|
4993
|
+
loadTarget
|
|
4994
|
+
};
|
|
4995
|
+
}
|
|
4996
|
+
/**
|
|
4953
4997
|
* Feed the resolved filter of a guarded verb to the distribution guard.
|
|
4954
4998
|
* The slot follows `OP_REGISTRY[op].policyKey` (`query` vs `filters`);
|
|
4955
4999
|
* the portable aggregate verbs ALSO carry the caller's filter on
|
|
@@ -5185,8 +5229,14 @@ var Repository = class extends RepositoryBase {
|
|
|
5185
5229
|
if (Array.isArray(labels) && (labels.includes("TransientTransactionError") || labels.includes("UnknownTransactionCommitResult")) || typeof hasLabel === "function" && (hasLabel.call(error, "TransientTransactionError") || hasLabel.call(error, "UnknownTransactionCommitResult"))) return error;
|
|
5186
5230
|
if (error instanceof mongoose.mongo.MongoOperationTimeoutError) return error;
|
|
5187
5231
|
if (error instanceof mongoose.Error.ValidationError) {
|
|
5188
|
-
const
|
|
5189
|
-
|
|
5232
|
+
const errs = Object.values(error.errors);
|
|
5233
|
+
const http = createError(400, `Validation Error: ${errs.map((e) => e.message).join(", ")}`);
|
|
5234
|
+
http.validationErrors = errs.map((e) => ({
|
|
5235
|
+
validator: e.kind ?? "mongoose",
|
|
5236
|
+
error: e.message,
|
|
5237
|
+
...e.path ? { path: e.path } : {}
|
|
5238
|
+
}));
|
|
5239
|
+
return http;
|
|
5190
5240
|
}
|
|
5191
5241
|
if (error instanceof mongoose.Error.CastError) return createError(400, `Invalid ${error.path}: ${error.value}`);
|
|
5192
5242
|
const duplicateErr = parseDuplicateKeyError(error);
|
|
@@ -5196,4 +5246,4 @@ var Repository = class extends RepositoryBase {
|
|
|
5196
5246
|
}
|
|
5197
5247
|
};
|
|
5198
5248
|
//#endregion
|
|
5199
|
-
export {
|
|
5249
|
+
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 };
|