@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.
Files changed (51) hide show
  1. package/README.md +71 -0
  2. package/dist/{PaginationEngine-D-P19Y9m.d.mts → PaginationEngine-CRHyuUtw.d.mts} +17 -3
  3. package/dist/{PaginationEngine-DQ7beTCR.mjs → PaginationEngine-CfT2Cxkj.mjs} +320 -145
  4. package/dist/{Repository-D7fDpyFx.mjs → Repository-CYhSC9ut.mjs} +844 -794
  5. package/dist/{Repository-IxI983_m.d.mts → Repository-pnLw1rG5.d.mts} +113 -47
  6. package/dist/actions/index.d.mts +1 -1
  7. package/dist/actions/index.mjs +1 -1
  8. package/dist/adapter/index.mjs +1 -1
  9. package/dist/{adapter-BrVfPFSW.mjs → adapter-CkMoMF83.mjs} +1 -1
  10. package/dist/ai/index.d.mts +1 -1
  11. package/dist/better-auth/index.mjs +2 -2
  12. package/dist/{create-repository-CRXjDa3N.d.mts → create-repository-BUx9vfG9.d.mts} +5 -5
  13. package/dist/{create-repository-DOe9E_5F.mjs → create-repository-BYyWzLbt.mjs} +2 -2
  14. package/dist/idempotency/index.d.mts +23 -0
  15. package/dist/idempotency/index.mjs +134 -0
  16. package/dist/{index-CmjNyulu.d.mts → index-SYF8N28D.d.mts} +37 -19
  17. package/dist/index.d.mts +105 -39
  18. package/dist/index.mjs +332 -676
  19. package/dist/indexes/verify-indexes.d.mts +14 -1
  20. package/dist/indexes/verify-indexes.mjs +54 -7
  21. package/dist/join-scope-DSAznE8E.mjs +168 -0
  22. package/dist/{mongooseToJsonSchema-waUVQwK4.mjs → mongooseToJsonSchema-C3k4ide0.mjs} +5 -1
  23. package/dist/{multi-tenant.plugin-B-tzNeYL.d.mts → multi-tenant.plugin-a3UhQARj.d.mts} +19 -3
  24. package/dist/{operations-CrRIoc8Z.d.mts → operations-XiFgG5US.d.mts} +36 -13
  25. package/dist/outbox/index.d.mts +1 -1
  26. package/dist/outbox/index.mjs +6 -4
  27. package/dist/pagination/PaginationEngine.d.mts +2 -2
  28. package/dist/pagination/PaginationEngine.mjs +1 -1
  29. package/dist/pagination/utils/limits.d.mts +1 -1
  30. package/dist/{pagination-BpIv2bdS.d.mts → pagination-D6psses0.d.mts} +116 -13
  31. package/dist/{plugin-options-BuvtOSZf.d.mts → plugin-options-CRhIvSzx.d.mts} +16 -2
  32. package/dist/plugins/index.d.mts +8 -3
  33. package/dist/plugins/index.mjs +98 -25
  34. package/dist/query-defaults-BxUcSOoF.mjs +208 -0
  35. package/dist/read-model/index.d.mts +66 -0
  36. package/dist/read-model/index.mjs +193 -0
  37. package/dist/{repository-ZbLtL7Nk.d.mts → repository-DNHfjAZX.d.mts} +36 -4
  38. package/dist/sync/index.d.mts +53 -0
  39. package/dist/sync/index.mjs +371 -0
  40. package/dist/tenant/index.mjs +2 -0
  41. package/dist/testkit/index.d.mts +40 -3
  42. package/dist/testkit/index.mjs +113 -2
  43. package/dist/{timestamp.plugin-B5eiaItQ.mjs → timestamp.plugin-zfdLYZiO.mjs} +135 -32
  44. package/dist/{to-plain-B8ufk3Qz.d.mts → to-plain-CjgEInWV.d.mts} +2 -2
  45. package/dist/transaction-BrRYtH_b.mjs +655 -0
  46. package/dist/{update-c_01A0if.mjs → update-WgeAUJ_I.mjs} +212 -33
  47. package/dist/utils/index.d.mts +1 -1
  48. package/dist/utils/index.mjs +1 -1
  49. package/dist/{validation-chain.plugin-Bju1D1Sn.mjs → validation-chain.plugin-CHUHWMtk.mjs} +204 -279
  50. package/dist/{validation-chain.plugin-DvrLJIbJ.d.mts → validation-chain.plugin-CTcNJCmV.d.mts} +27 -5
  51. 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 compileFilterToMongo, a as count, 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 create, y as createMany } from "./update-c_01A0if.mjs";
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 { c as resolveCursorSecrets, l as verifySignature, s as attachSignature, t as PaginationEngine } from "./PaginationEngine-DQ7beTCR.mjs";
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 { RepositoryBase, createDistributionGuard, runChunkedArchive, runChunkedPurge, throwIfAborted, validatePluginOrder, withRetry } from "@classytic/repo-core/repository";
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 !== void 0) aggregation.option({ hint: hints.indexHint });
532
- }
533
- //#endregion
534
- //#region src/filter/cast-schema.ts
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
- * Cast the operand(s) of a field-level operator object, leaving operators we
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
- //#endregion
650
- //#region src/actions/aggregate-ir/normalize.ts
313
+ const checked = /* @__PURE__ */ new WeakMap();
651
314
  /**
652
- * AggRequest → input normalization (mongokit binding).
653
- *
654
- * Re-exports the kit-neutral helpers from `@classytic/repo-core/aggregate`
655
- * with the mongokit error-prefix pre-bound, so call sites stay free of
656
- * boilerplate. The actual logic lives in repo-core — when the rules
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 validateMeasures$1(measures) {
661
- validateMeasures(measures, "mongokit");
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\` must be Filter IR (\`{ op, field, value }\`) or a MongoDB aggregation expression (\`{ \$eq: ['\$field', value] }\`), got ${keys.length === 0 ? "an empty object" : `an object keyed by ${keys.map((k) => `'${k}'`).join(", ")}`}. Query syntax such as \`{ field: { eq: value } }\` works in \`filter\` but not here — evaluated as an expression it is always TRUE, so the filter would silently match every row.`);
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": return filter.exists ? { $ne: [`$${filter.field}`, null] } : { $eq: [`$${filter.field}`, null] };
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 finalPipeline = [...pipeline.slice(0, prePaginationIndex), { $count: "n" }];
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 aggregation = Model.aggregate(pipeline);
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 aggregation = Model.aggregate(pipeline);
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. Its result envelope cannot carry \`totalIsLowerBound\`, and an unflagged ceiling reads as a total. Use getAll()/aggregatePaginate() where a bounded count matters.`);
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, options));
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, options));
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
- }, context));
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, context));
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
- if (unique.length === 0) return /* @__PURE__ */ new Map();
2904
- const docs = await this.findAll({ [field]: { $in: unique } }, options);
2905
- return new Map(docs.map((d) => [String(d[field]), d]));
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, context));
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, context));
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
- if (options.readPreference) query.read(options.readPreference);
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
- if (options.readPreference) query.read(options.readPreference);
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
- maxTimeMS: context.maxTimeMS ?? params.maxTimeMS,
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, options));
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, options));
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, context));
3630
- else result = await this._withResilience(context, () => update(this.Model, id, context.data || data, context));
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, { session: options.session }) : deleteById(this.Model, id, {
4127
+ const result = await this._withResilience(context, () => deleteQuery ? deleteByQuery(this.Model, deleteQuery, {
4210
4128
  session: options.session,
4211
- query: context.query
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
- return await this.Model.updateMany(finalQuery, finalData, {
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
- }).exec();
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, () => this.Model.deleteMany(finalQuery, { session: options.session }).exec());
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 finalPipeline = [...pipeline];
4402
- if (context.query && Object.keys(context.query).length > 0) finalPipeline.unshift({ $match: context.query });
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
- if (options.allowDiskUse) aggregation.allowDiskUse(true);
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
- return { rows: await executeAgg(this.Model, finalReq, { session: context.session }) };
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
- const session = context.session;
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 finalReq = this._injectPolicyScopeIntoAgg(req, context);
4535
- const result = await this._executeAggregatePaginate(finalReq, useKeyset, limit, session);
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, session) {
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 aggregation = this.Model.aggregate([...headStages, ...tailStages]);
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
- }, session ? { session } : {});
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
- }, session ? { session } : {}), countAggGroups(this.Model, finalReq, session ? { session } : {})]);
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
- readPreference
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 = downgradeCappedCount(context.countStrategy ?? options.countStrategy ?? this._pagination.config.defaultCountStrategy, "lookupPopulate");
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-DQ7beTCR.mjs").then((n) => n.n);
4720
- const { encodeCursor, resolveCursorFilter } = await import("./PaginationEngine-DQ7beTCR.mjs").then((n) => n.r);
4721
- const { getPrimaryField } = await import("./PaginationEngine-DQ7beTCR.mjs").then((n) => n.n);
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 matchFilters = after ? resolveCursorFilter(after, normalizedSort, cursorVersion, { ...filters || {} }, minCursorVersion, normalizedSort, this._pagination.config.cursorSecret) : { ...filters || {} };
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
- if (readPref) aggregation.read(readPref);
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
- if (skip > 1e4) warn(`[mongokit] Large offset (${skip}) in lookupPopulate. Consider using keyset pagination: getAll({ sort, after, limit, lookups })`);
4759
- const dataPipeline = [];
4760
- if (filters && Object.keys(filters).length > 0) dataPipeline.push({ $match: filters });
4761
- if (sort) dataPipeline.push({ $sort: this._parseSort(sort) });
4762
- if (countStrategy === "none") {
4763
- dataPipeline.push({ $skip: skip }, { $limit: limit + 1 });
4764
- appendLookupStages$2(dataPipeline);
4765
- const aggregation = this.Model.aggregate(dataPipeline).session(session || null);
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
- if (readPref) aggregation.read(readPref);
4768
- const data = await aggregation;
4769
- const hasNext = data.length > limit;
4770
- if (hasNext) data.pop();
4771
- const result = {
4772
- method: "offset",
4773
- data,
4774
- page,
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
- pages: 0,
4778
- hasNext,
4779
- hasPrev: page > 1
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 total = facetResult.metadata[0]?.total || 0;
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: facetResult.data || [],
4808
+ data,
4807
4809
  page,
4808
4810
  limit,
4809
- total,
4810
- pages: calculateTotalPages(total, limit),
4811
- hasNext: page * limit < total,
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, normalized);
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 messages = Object.values(error.errors).map((err) => err.message);
5189
- return createError(400, `Validation Error: ${messages.join(", ")}`);
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 { resetCapabilityProbeCache as _, supportsTransactions as a, transactionResolutionOf as b, ALL_OPERATIONS as c, READ_OPERATIONS as d, operationsByPolicyKey as f, probeMongoCapabilities as g, classifyHelloReply as h, isTransactionUnsupported as i, MUTATING_OPERATIONS as l, capabilitiesForSupport as m, Repository as n, withTransaction as o, MONGOKIT_CAPABILITIES as p, batchTransaction as r, AggregationBuilder as s, HOOK_PRIORITY$1 as t, OP_REGISTRY as u, resolveMongoCapabilities as v, createMongoPurgePort as x, resolveTransactionSupport as y };
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 };