@classytic/mongokit 3.40.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 (46) 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-Dvl8jcRY.mjs → PaginationEngine-CfT2Cxkj.mjs} +296 -137
  4. package/dist/{Repository-BMgPSkqZ.mjs → Repository-CYhSC9ut.mjs} +571 -175
  5. package/dist/{Repository-CR8Y9g2E.d.mts → Repository-pnLw1rG5.d.mts} +57 -16
  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-CAle1MSU.d.mts → create-repository-BUx9vfG9.d.mts} +5 -5
  13. package/dist/{create-repository-BzBUMvd1.mjs → create-repository-BYyWzLbt.mjs} +2 -2
  14. package/dist/{index-CmjNyulu.d.mts → index-SYF8N28D.d.mts} +37 -19
  15. package/dist/index.d.mts +55 -15
  16. package/dist/index.mjs +57 -11
  17. package/dist/join-scope-DSAznE8E.mjs +168 -0
  18. package/dist/{mongooseToJsonSchema-waUVQwK4.mjs → mongooseToJsonSchema-C3k4ide0.mjs} +5 -1
  19. package/dist/{multi-tenant.plugin-CBgCo9im.d.mts → multi-tenant.plugin-a3UhQARj.d.mts} +2 -2
  20. package/dist/{operations-CrRIoc8Z.d.mts → operations-XiFgG5US.d.mts} +36 -13
  21. package/dist/outbox/index.d.mts +1 -1
  22. package/dist/outbox/index.mjs +1 -1
  23. package/dist/pagination/PaginationEngine.d.mts +2 -2
  24. package/dist/pagination/PaginationEngine.mjs +1 -1
  25. package/dist/pagination/utils/limits.d.mts +1 -1
  26. package/dist/{pagination-BpIv2bdS.d.mts → pagination-D6psses0.d.mts} +116 -13
  27. package/dist/{plugin-options-Zk46lD6F.d.mts → plugin-options-CRhIvSzx.d.mts} +2 -2
  28. package/dist/plugins/index.d.mts +3 -3
  29. package/dist/plugins/index.mjs +2 -2
  30. package/dist/query-defaults-BxUcSOoF.mjs +208 -0
  31. package/dist/read-model/index.d.mts +66 -0
  32. package/dist/read-model/index.mjs +193 -0
  33. package/dist/{repository-odl03582.d.mts → repository-DNHfjAZX.d.mts} +16 -2
  34. package/dist/sync/index.d.mts +15 -2
  35. package/dist/sync/index.mjs +38 -1
  36. package/dist/testkit/index.d.mts +40 -3
  37. package/dist/testkit/index.mjs +113 -2
  38. package/dist/{timestamp.plugin-DF0gJB6p.mjs → timestamp.plugin-zfdLYZiO.mjs} +50 -31
  39. package/dist/{to-plain-k7SjNlSS.d.mts → to-plain-CjgEInWV.d.mts} +2 -2
  40. package/dist/{transaction-B5L9glO0.mjs → transaction-BrRYtH_b.mjs} +127 -5
  41. package/dist/{update-DG0fCwRJ.mjs → update-WgeAUJ_I.mjs} +44 -22
  42. package/dist/utils/index.d.mts +1 -1
  43. package/dist/utils/index.mjs +1 -1
  44. package/dist/{validation-chain.plugin-BLY2gLEk.mjs → validation-chain.plugin-CHUHWMtk.mjs} +15 -3
  45. package/dist/{validation-chain.plugin-DYp9E7K2.d.mts → validation-chain.plugin-CTcNJCmV.d.mts} +4 -4
  46. package/package.json +8 -3
@@ -1,13 +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 SHORTHAND_OPS, a as count, b as create, c as getByQuery, d as deleteById, f as deleteByQuery, g as LookupBuilder, h as distinct, l as getOrCreate, n as update, o as exists, r as updateByQuery, s as getById, t as findOneAndUpdate, v as compileFilterToMongo, x as createMany, y as castFilterToSchema } from "./update-DG0fCwRJ.mjs";
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 { a as createTxBoundRepo, d as resolveMongoCapabilities, i as withTransaction } from "./transaction-B5L9glO0.mjs";
7
- import { c as resolveCursorSecrets, l as verifySignature, s as attachSignature, t as PaginationEngine } from "./PaginationEngine-Dvl8jcRY.mjs";
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";
8
9
  import { calculateTotalPages } from "./pagination/utils/limits.mjs";
9
10
  import { hasNearOperator, rewriteNearForCount } from "./query/primitives/geo.mjs";
10
- 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";
11
13
  import mongoose from "mongoose";
12
14
  import { isFilter } from "@classytic/repo-core/filter";
13
15
  import { VersionConflictError, conservativeMongoIsTransientConflict } from "@classytic/repo-core/errors";
@@ -191,6 +193,11 @@ const OP_REGISTRY = {
191
193
  mutates: true,
192
194
  hasIdContext: false
193
195
  },
196
+ bulkUpsert: {
197
+ policyKey: "operations",
198
+ mutates: true,
199
+ hasIdContext: false
200
+ },
194
201
  getById: {
195
202
  policyKey: "query",
196
203
  mutates: false,
@@ -246,6 +253,11 @@ const OP_REGISTRY = {
246
253
  mutates: false,
247
254
  hasIdContext: false
248
255
  },
256
+ iterate: {
257
+ policyKey: "query",
258
+ mutates: false,
259
+ hasIdContext: false
260
+ },
249
261
  watch: {
250
262
  policyKey: "query",
251
263
  mutates: false,
@@ -316,20 +328,6 @@ function assertExpectedIndex(model, leadingKeys) {
316
328
  checked.set(model, (done ?? /* @__PURE__ */ new Set()).add(id));
317
329
  }
318
330
  //#endregion
319
- //#region src/actions/aggregate-ir/normalize.ts
320
- /**
321
- * AggRequest → input normalization (mongokit binding).
322
- *
323
- * Re-exports the kit-neutral helpers from `@classytic/repo-core/aggregate`
324
- * with the mongokit error-prefix pre-bound, so call sites stay free of
325
- * boilerplate. The actual logic lives in repo-core — when the rules
326
- * for what a valid AggRequest looks like change, update the IR
327
- * contract there.
328
- */
329
- function validateMeasures$1(measures) {
330
- validateMeasures(measures, "mongokit");
331
- }
332
- //#endregion
333
331
  //#region src/actions/aggregate-ir/dateBucket.ts
334
332
  /**
335
333
  * Compile a `AggDateBucket` to the mongo expression that produces the
@@ -845,6 +843,20 @@ function compileMeasure(measure) {
845
843
  }
846
844
  }
847
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
848
860
  //#region src/actions/aggregate-ir/pipeline.ts
849
861
  function buildAggPipeline(req, schema) {
850
862
  validateMeasures$1(req.measures);
@@ -1239,19 +1251,34 @@ function irReferencesAlias(filter, aliasSet) {
1239
1251
  return false;
1240
1252
  }
1241
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
1242
1274
  //#region src/actions/aggregate-ir/count.ts
1243
1275
  async function countAggGroups(Model, req, options = {}) {
1244
- const session = options.session;
1245
1276
  const groupCols = normalizeGroupBy(req.groupBy);
1246
1277
  const hasDottedGroupBy = groupCols.some((f) => f.includes("."));
1247
1278
  const hasDateBuckets = !!req.dateBuckets && Object.keys(req.dateBuckets).length > 0;
1248
1279
  if (req.having !== void 0 || req.lookups !== void 0 && req.lookups.length > 0 || hasDottedGroupBy || hasDateBuckets) {
1249
1280
  const { pipeline, prePaginationIndex } = buildAggPipeline(req, Model.schema);
1250
- const finalPipeline = [...pipeline.slice(0, prePaginationIndex), { $count: "n" }];
1251
- const aggregation = Model.aggregate(finalPipeline);
1252
- if (session) aggregation.session(session);
1253
- applyExecutionHints(aggregation, req.executionHints);
1254
- const [row] = await aggregation.exec();
1281
+ const [row] = await runAgg(Model, [...pipeline.slice(0, prePaginationIndex), { $count: "n" }], req, options);
1255
1282
  return row?.n ?? 0;
1256
1283
  }
1257
1284
  const match = req.filter ? compileFilterToMongo(castFilterToSchema(req.filter, Model.schema), Model.schema) : {};
@@ -1260,10 +1287,7 @@ async function countAggGroups(Model, req, options = {}) {
1260
1287
  if (Object.keys(match).length > 0) pipeline.push({ $match: match });
1261
1288
  pipeline.push({ $limit: 1 });
1262
1289
  pipeline.push({ $count: "n" });
1263
- const aggregation = Model.aggregate(pipeline);
1264
- if (session) aggregation.session(session);
1265
- applyExecutionHints(aggregation, req.executionHints);
1266
- const [row] = await aggregation.exec();
1290
+ const [row] = await runAgg(Model, pipeline, req, options);
1267
1291
  return row?.n ?? 0;
1268
1292
  }
1269
1293
  const groupId = groupCols.reduce((acc, field) => {
@@ -1274,22 +1298,10 @@ async function countAggGroups(Model, req, options = {}) {
1274
1298
  if (Object.keys(match).length > 0) pipeline.push({ $match: match });
1275
1299
  pipeline.push({ $group: { _id: groupId } });
1276
1300
  pipeline.push({ $count: "n" });
1277
- const aggregation = Model.aggregate(pipeline);
1278
- if (session) aggregation.session(session);
1279
- const [row] = await aggregation.exec();
1301
+ const [row] = await runAgg(Model, pipeline, req, options);
1280
1302
  return row?.n ?? 0;
1281
1303
  }
1282
1304
  //#endregion
1283
- //#region src/actions/aggregate-ir/execute.ts
1284
- async function executeAgg(Model, req, options = {}) {
1285
- const session = options.session;
1286
- const { pipeline } = buildAggPipeline(req, Model.schema);
1287
- const aggregation = Model.aggregate(pipeline);
1288
- if (session) aggregation.session(session);
1289
- applyExecutionHints(aggregation, req.executionHints);
1290
- return await aggregation.exec();
1291
- }
1292
- //#endregion
1293
1305
  //#region src/actions/aggregate-ir/keyset.ts
1294
1306
  /**
1295
1307
  * Keyset (cursor) pagination helpers — mongokit binding.
@@ -1375,6 +1387,188 @@ function createMongoArchivePort(repo, filter, session) {
1375
1387
  };
1376
1388
  }
1377
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
1378
1572
  //#region src/query/AggregationBuilder.ts
1379
1573
  /**
1380
1574
  * Normalize SortSpec to MongoDB's strict format (1 | -1)
@@ -2094,7 +2288,7 @@ const HOOK_PRIORITY$1 = HOOK_PRIORITY;
2094
2288
  */
2095
2289
  function downgradeCappedCount(strategy, where) {
2096
2290
  if (strategy !== "capped") return strategy;
2097
- warn(`[mongokit] countStrategy 'capped' is not supported by ${where} — counting exactly. Its result envelope cannot carry \`totalIsLowerBound\`, and an unflagged ceiling reads as a total. Use getAll()/aggregatePaginate() where a bounded count matters.`);
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.`);
2098
2292
  return "exact";
2099
2293
  }
2100
2294
  var Repository = class extends RepositoryBase {
@@ -2160,6 +2354,8 @@ var Repository = class extends RepositoryBase {
2160
2354
  * {@link ID_ADDRESSED_OPERATIONS}.
2161
2355
  */
2162
2356
  _guardIdAddressed = false;
2357
+ /** This repository's own `queryDefaults` / `aggregateDefaults` (deployment ones fill the gaps). */
2358
+ _queryDefaults;
2163
2359
  constructor(Model, plugins = [], paginationConfig = {}, options = {}) {
2164
2360
  super({
2165
2361
  name: Model.modelName,
@@ -2173,6 +2369,11 @@ var Repository = class extends RepositoryBase {
2173
2369
  warn(m);
2174
2370
  });
2175
2371
  this.Model = Model;
2372
+ markGoverned(Model);
2373
+ this._queryDefaults = {
2374
+ query: options.queryDefaults,
2375
+ aggregate: options.aggregateDefaults
2376
+ };
2176
2377
  this._capabilitiesOverride = options.capabilities;
2177
2378
  this.model = Model.modelName;
2178
2379
  this._pagination = new PaginationEngine(Model, paginationConfig);
@@ -2417,6 +2618,31 @@ var Repository = class extends RepositoryBase {
2417
2618
  * `_buildContext` inputs), so every op routed through `_runOp` — and the
2418
2619
  * inline-try methods that call this directly — honors them uniformly.
2419
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
+ }
2420
2646
  _withResilience(context, fn) {
2421
2647
  const signal = context.signal;
2422
2648
  const retryPolicy = context.retryPolicy;
@@ -2431,7 +2657,10 @@ var Repository = class extends RepositoryBase {
2431
2657
  data,
2432
2658
  ...options
2433
2659
  });
2434
- 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
+ }));
2435
2664
  }
2436
2665
  /**
2437
2666
  * Create multiple documents
@@ -2441,7 +2670,10 @@ var Repository = class extends RepositoryBase {
2441
2670
  dataArray,
2442
2671
  ...options
2443
2672
  });
2444
- 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
+ }));
2445
2677
  }
2446
2678
  /**
2447
2679
  * Get document by ID
@@ -2469,7 +2701,10 @@ var Repository = class extends RepositoryBase {
2469
2701
  const result = await this._withResilience(context, () => getByQuery(this.Model, {
2470
2702
  [effectiveIdField]: id,
2471
2703
  ...context.query || {}
2472
- }, context));
2704
+ }, {
2705
+ ...context,
2706
+ queryOptions: this._opts("read", context)
2707
+ }));
2473
2708
  await this._emitHook("after:getById", {
2474
2709
  context,
2475
2710
  result
@@ -2486,7 +2721,10 @@ var Repository = class extends RepositoryBase {
2486
2721
  });
2487
2722
  return null;
2488
2723
  }
2489
- 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
+ }));
2490
2728
  if (!result && wantsThrow) throw createError(404, "Document not found");
2491
2729
  await this._emitHook("after:getById", {
2492
2730
  context,
@@ -2502,39 +2740,21 @@ var Repository = class extends RepositoryBase {
2502
2740
  }
2503
2741
  });
2504
2742
  }
2505
- /**
2506
- * Batch point-read — the `$in` counterpart to {@link getById}. Fetches every
2507
- * doc whose id (the repo's `idField`, default `_id`) is in `ids` in ONE query,
2508
- * routed through `findAll` so tenant + soft-delete scoping and hooks apply
2509
- * exactly as a single read would. Returns a Map keyed by the stringified id;
2510
- * ids are de-duplicated and ids with no matching doc are simply absent (no
2511
- * throw — mirrors `getById(..., { throwOnNotFound: false })`). Structurally
2512
- * INVALID ids (wrong ObjectId shape, non-numeric for a Number id field, ...)
2513
- * are dropped the same way — an id that cannot exist in the collection is
2514
- * unambiguously a miss, and one malformed id must never poison the rest of
2515
- * the batch. Lean by default (findAll semantics) — pass `{ lean: false }`
2516
- * for hydrated docs.
2517
- *
2518
- * The N+1 killer for hosts that resolve many ids in one tick (order-line
2519
- * snapshotting, availability matrices, dashboard row enrichment):
2520
- *
2521
- * @example
2522
- * const byId = await repo.getByIds(offerIds, ctx);
2523
- * const doc = byId.get(offerId); // O(1); undefined if not found
2524
- *
2525
- * Sizing: there is no library-imposed cap — the bounds are MongoDB's (the
2526
- * `$in` array must fit the 16 MB query document; `$in` on an indexed field
2527
- * is n point-lookups). Keep batches ≤ ~10k ids per call and chunk beyond
2528
- * that. Don't exclude the id field via `select` — result keys derive from
2529
- * it.
2530
- */
2531
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)}`);
2532
2746
  const field = options.idField ?? this.idField;
2533
2747
  const idType = getSchemaIdType(this.Model.schema, field);
2534
2748
  const unique = [...new Set(ids.map((id) => String(id)))].filter((id) => isValidIdForType(id, idType));
2535
- if (unique.length === 0) return /* @__PURE__ */ new Map();
2536
- const docs = await this.findAll({ [field]: { $in: unique } }, options);
2537
- 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;
2538
2758
  }
2539
2759
  /**
2540
2760
  * Get single document by query
@@ -2556,7 +2776,10 @@ var Repository = class extends RepositoryBase {
2556
2776
  return cachedResult;
2557
2777
  }
2558
2778
  const finalQuery = context.query || query;
2559
- 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
+ }));
2560
2783
  }
2561
2784
  /**
2562
2785
  * Get single document by arbitrary filter.
@@ -2585,7 +2808,10 @@ var Repository = class extends RepositoryBase {
2585
2808
  }
2586
2809
  const finalQuery = context.query || query;
2587
2810
  try {
2588
- 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
+ }));
2589
2815
  await this._emitHook("after:getOne", {
2590
2816
  context,
2591
2817
  result
@@ -2623,13 +2849,68 @@ var Repository = class extends RepositoryBase {
2623
2849
  if (options.populate || context.populate) query.populate(this._parsePopulate(context.populate || options.populate));
2624
2850
  if (context.lean ?? options.lean ?? true) query.lean();
2625
2851
  if (options.session) query.session(options.session);
2626
- if (options.readPreference) query.read(options.readPreference);
2852
+ applyToQuery(query, this._opts("read", context));
2627
2853
  const limitSpec = context.limit ?? options.limit;
2628
2854
  if (typeof limitSpec === "number" && limitSpec > 0) query.limit(limitSpec);
2629
2855
  return await query.exec();
2630
2856
  });
2631
2857
  }
2632
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
+ /**
2633
2914
  * Streaming reads — async iterator over data, suitable for migrations,
2634
2915
  * backfills, schema audits, and any once-a-quarter "touch every row"
2635
2916
  * job. Goes through the standard `before:cursor` hook pipeline so
@@ -2670,7 +2951,9 @@ var Repository = class extends RepositoryBase {
2670
2951
  if (options.batchSize) query.batchSize(options.batchSize);
2671
2952
  if (context.lean ?? options.lean ?? true) query.lean();
2672
2953
  if (options.session) query.session(options.session);
2673
- 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);
2674
2957
  const stream = query.cursor();
2675
2958
  let yieldedCount = 0;
2676
2959
  try {
@@ -2952,7 +3235,12 @@ var Repository = class extends RepositoryBase {
2952
3235
  lean: context.lean ?? options.lean ?? true,
2953
3236
  session: options.session,
2954
3237
  hint: context.hint ?? params.hint,
2955
- 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
+ }),
2956
3244
  countLimit: context.countLimit ?? params.countLimit,
2957
3245
  readPreference: context.readPreference ?? options.readPreference ?? params.readPreference,
2958
3246
  collation: context.collation ?? params.collation
@@ -3034,7 +3322,10 @@ var Repository = class extends RepositoryBase {
3034
3322
  query,
3035
3323
  ...options
3036
3324
  });
3037
- 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
+ }));
3038
3329
  }
3039
3330
  /**
3040
3331
  * Check if document exists
@@ -3045,7 +3336,10 @@ var Repository = class extends RepositoryBase {
3045
3336
  query,
3046
3337
  ...options
3047
3338
  });
3048
- 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
+ }));
3049
3343
  }
3050
3344
  /**
3051
3345
  * Update a document by id and return the post-update doc.
@@ -3211,7 +3505,8 @@ var Repository = class extends RepositoryBase {
3211
3505
  casUpdate,
3212
3506
  {
3213
3507
  ...context,
3214
- throwOnNotFound: false
3508
+ throwOnNotFound: false,
3509
+ queryOptions: this._opts("findAndModify", context)
3215
3510
  }
3216
3511
  ));
3217
3512
  if (!result) {
@@ -3235,8 +3530,14 @@ var Repository = class extends RepositoryBase {
3235
3530
  } else if (effectiveIdField !== "_id") result = await this._withResilience(context, () => updateByQuery(this.Model, {
3236
3531
  [effectiveIdField]: id,
3237
3532
  ...context.query || {}
3238
- }, context.data || data, context));
3239
- 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
+ }));
3240
3541
  if (!result && wantsThrow) throw createError(404, "Document not found");
3241
3542
  await this._emitHook("after:update", {
3242
3543
  context,
@@ -3296,7 +3597,8 @@ var Repository = class extends RepositoryBase {
3296
3597
  sort: options.sort,
3297
3598
  returnDocument: options.returnDocument ?? "after",
3298
3599
  upsert: options.upsert ?? false,
3299
- session: options.session
3600
+ session: options.session,
3601
+ queryOptions: this._opts("findAndModify", context)
3300
3602
  });
3301
3603
  });
3302
3604
  }
@@ -3525,7 +3827,8 @@ var Repository = class extends RepositoryBase {
3525
3827
  return await findOneAndUpdate(this.Model, finalQuery, finalUpdate, {
3526
3828
  returnDocument: "after",
3527
3829
  upsert: options.upsert ?? false,
3528
- session: options.session
3830
+ session: options.session,
3831
+ queryOptions: this._opts("findAndModify", context)
3529
3832
  });
3530
3833
  });
3531
3834
  }
@@ -3758,7 +4061,8 @@ var Repository = class extends RepositoryBase {
3758
4061
  return await findOneAndUpdate(this.Model, finalQuery, finalUpdate, {
3759
4062
  returnDocument: "after",
3760
4063
  upsert: false,
3761
- session: options.session
4064
+ session: options.session,
4065
+ queryOptions: this._opts("findAndModify", context)
3762
4066
  });
3763
4067
  });
3764
4068
  }
@@ -3820,9 +4124,13 @@ var Repository = class extends RepositoryBase {
3820
4124
  [effectiveIdField]: id,
3821
4125
  ...context.query || {}
3822
4126
  } : void 0;
3823
- 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, {
3824
4128
  session: options.session,
3825
- 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)
3826
4134
  }));
3827
4135
  if (!result && wantsThrow) throw createError(404, "Document not found");
3828
4136
  await this._emitHook("after:delete", {
@@ -3870,11 +4178,13 @@ var Repository = class extends RepositoryBase {
3870
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.");
3871
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.");
3872
4180
  const finalData = context.data || normalizedData;
3873
- return await this.Model.updateMany(finalQuery, finalData, {
4181
+ const updateQuery = this.Model.updateMany(finalQuery, finalData, {
3874
4182
  runValidators: true,
3875
4183
  session: options.session,
3876
4184
  ...options.updatePipeline !== void 0 ? { updatePipeline: options.updatePipeline } : {}
3877
- }).exec();
4185
+ });
4186
+ applyToQuery(updateQuery, this._opts("write", context));
4187
+ return await updateQuery.exec();
3878
4188
  });
3879
4189
  }
3880
4190
  /**
@@ -3916,7 +4226,11 @@ var Repository = class extends RepositoryBase {
3916
4226
  }
3917
4227
  const finalQuery = context.query || filter;
3918
4228
  if (!finalQuery || Object.keys(finalQuery).length === 0) throw createError(400, "deleteMany requires a non-empty query filter after policy hooks.");
3919
- 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
+ })());
3920
4234
  await this._emitHook("after:deleteMany", {
3921
4235
  context,
3922
4236
  result
@@ -3979,6 +4293,70 @@ var Repository = class extends RepositoryBase {
3979
4293
  return runChunkedArchive(options, sink, port);
3980
4294
  }
3981
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
+ /**
3982
4360
  * Kit-native aggregation pipeline — takes a MongoDB stage array and
3983
4361
  * returns the raw pipeline output. Use this for `$lookup`, `$unwind`,
3984
4362
  * `$facet`, `$graphLookup`, and any other mongo-specific power
@@ -4012,15 +4390,10 @@ var Repository = class extends RepositoryBase {
4012
4390
  const maxStages = options.maxPipelineStages;
4013
4391
  if (maxStages && pipeline.length > maxStages) throw createError(400, `Aggregation pipeline exceeds maximum allowed stages (${pipeline.length} > ${maxStages})`);
4014
4392
  return this._runOp("aggregatePipeline", context, async () => {
4015
- const finalPipeline = [...pipeline];
4016
- if (context.query && Object.keys(context.query).length > 0) finalPipeline.unshift({ $match: context.query });
4017
- 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));
4018
4395
  if (options.session) aggregation.session(options.session);
4019
- if (options.allowDiskUse) aggregation.allowDiskUse(true);
4020
- if (options.readPreference) aggregation.read(options.readPreference);
4021
- if (options.maxTimeMS) aggregation.option({ maxTimeMS: options.maxTimeMS });
4022
- if (options.comment) aggregation.option({ comment: options.comment });
4023
- if (options.readConcern) aggregation.option({ readConcern: options.readConcern });
4396
+ applyToAggregate(aggregation, this._opts("aggregate", context));
4024
4397
  if (options.collation) aggregation.collation(options.collation);
4025
4398
  return await aggregation.exec();
4026
4399
  });
@@ -4043,7 +4416,8 @@ var Repository = class extends RepositoryBase {
4043
4416
  if (context.filters && Object.keys(context.filters).length > 0) finalPipeline.unshift({ $match: context.filters });
4044
4417
  const aggOptions = {
4045
4418
  ...context,
4046
- pipeline: finalPipeline
4419
+ pipeline: this._scopeJoins(finalPipeline, context),
4420
+ queryOptions: this._opts("aggregate", context)
4047
4421
  };
4048
4422
  return this._runOp("aggregatePipelinePaginate", context, () => this._pagination.aggregatePaginate(aggOptions));
4049
4423
  }
@@ -4074,7 +4448,7 @@ var Repository = class extends RepositoryBase {
4074
4448
  aggRequest: req,
4075
4449
  ...options
4076
4450
  });
4077
- const cached = this._cachedValue(context);
4451
+ const cached = options.explain ? void 0 : this._cachedValue(context);
4078
4452
  if (cached !== void 0) {
4079
4453
  await this._emitHook("after:aggregate", {
4080
4454
  context,
@@ -4085,7 +4459,14 @@ var Repository = class extends RepositoryBase {
4085
4459
  }
4086
4460
  return this._runOp("aggregate", context, async () => {
4087
4461
  const finalReq = this._injectPolicyScopeIntoAgg(req, context);
4088
- 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
+ };
4089
4470
  });
4090
4471
  }
4091
4472
  /**
@@ -4132,7 +4513,7 @@ var Repository = class extends RepositoryBase {
4132
4513
  ...options
4133
4514
  });
4134
4515
  const limit = Math.max(1, Math.min(req.limit ?? 20, 1e3));
4135
- const session = context.session;
4516
+ context.session;
4136
4517
  const useKeyset = isKeysetMode(req);
4137
4518
  const cachedEnvelope = this._cachedValue(context);
4138
4519
  if (cachedEnvelope !== void 0) {
@@ -4145,8 +4526,12 @@ var Repository = class extends RepositoryBase {
4145
4526
  }
4146
4527
  return this._composeMiddleware("aggregatePaginate", context, async () => {
4147
4528
  try {
4148
- const finalReq = this._injectPolicyScopeIntoAgg(req, context);
4149
- 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));
4150
4535
  await this._emitHook("after:aggregatePaginate", {
4151
4536
  context,
4152
4537
  result
@@ -4170,7 +4555,7 @@ var Repository = class extends RepositoryBase {
4170
4555
  * built by the outer wrapper matches the request the executor
4171
4556
  * sees — no cross-tenant cache poisoning.
4172
4557
  */
4173
- async _executeAggregatePaginate(finalReq, useKeyset, limit, session) {
4558
+ async _executeAggregatePaginate(finalReq, useKeyset, limit, run) {
4174
4559
  if (useKeyset) {
4175
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");
4176
4561
  const cursor = finalReq.after ? decodeAggCursor$1(finalReq.after, this._pagination.config.cursorSecret) : void 0;
@@ -4181,10 +4566,7 @@ var Repository = class extends RepositoryBase {
4181
4566
  if (keysetMatch) tailStages.push(keysetMatch);
4182
4567
  tailStages.push({ $sort: finalReq.sort });
4183
4568
  tailStages.push({ $limit: limit + 1 });
4184
- const aggregation = this.Model.aggregate([...headStages, ...tailStages]);
4185
- if (session) aggregation.session(session);
4186
- applyExecutionHints(aggregation, finalReq.executionHints);
4187
- const peeked = await aggregation.exec();
4569
+ const peeked = await runAgg(this.Model, [...headStages, ...tailStages], finalReq, run);
4188
4570
  const hasMore = peeked.length > limit;
4189
4571
  const data = hasMore ? peeked.slice(0, limit) : peeked;
4190
4572
  return {
@@ -4198,12 +4580,13 @@ var Repository = class extends RepositoryBase {
4198
4580
  const page = Math.max(1, finalReq.page ?? 1);
4199
4581
  const countStrategy = downgradeCappedCount(finalReq.countStrategy ?? this._pagination.config.defaultCountStrategy, "aggregatePaginateIr");
4200
4582
  const offset = (page - 1) * limit;
4583
+ assertOffsetWithinCap(offset, this._pagination.config.maxOffset);
4201
4584
  if (countStrategy === "none") {
4202
4585
  const peek = await executeAgg(this.Model, {
4203
4586
  ...finalReq,
4204
4587
  limit: limit + 1,
4205
4588
  offset
4206
- }, session ? { session } : {});
4589
+ }, run);
4207
4590
  const hasNext = peek.length > limit;
4208
4591
  return {
4209
4592
  method: "offset",
@@ -4220,7 +4603,7 @@ var Repository = class extends RepositoryBase {
4220
4603
  ...finalReq,
4221
4604
  limit,
4222
4605
  offset
4223
- }, session ? { session } : {}), countAggGroups(this.Model, finalReq, session ? { session } : {})]);
4606
+ }, run), countAggGroups(this.Model, finalReq, run)]);
4224
4607
  return {
4225
4608
  method: "offset",
4226
4609
  data,
@@ -4278,10 +4661,9 @@ var Repository = class extends RepositoryBase {
4278
4661
  });
4279
4662
  return this._runOp("distinct", context, () => {
4280
4663
  const finalQuery = context.query || query;
4281
- const readPreference = context.readPreference ?? options.readPreference;
4282
4664
  return distinct(this.Model, field, finalQuery, {
4283
4665
  session: options.session,
4284
- readPreference
4666
+ queryOptions: this._opts("read", context)
4285
4667
  });
4286
4668
  });
4287
4669
  }
@@ -4319,24 +4701,24 @@ var Repository = class extends RepositoryBase {
4319
4701
  const filters = context.filters ?? options.filters;
4320
4702
  const sort = context.sort ?? options.sort;
4321
4703
  const limit = context.limit ?? options.limit ?? this._pagination.config.defaultLimit ?? 20;
4322
- const readPref = context.readPreference ?? options.readPreference;
4323
4704
  const session = context.session ?? options.session;
4324
4705
  const collation = context.collation ?? options.collation;
4325
4706
  const after = context.after ?? options.after;
4326
4707
  const pageFromContext = context.page ?? options.page;
4327
4708
  const isKeyset = !!after || !pageFromContext && !!sort;
4328
- const countStrategy = downgradeCappedCount(context.countStrategy ?? options.countStrategy ?? this._pagination.config.defaultCountStrategy, "lookupPopulate");
4709
+ const countStrategy = context.countStrategy ?? options.countStrategy ?? this._pagination.config.defaultCountStrategy;
4329
4710
  const projection = buildLookupProjection(context.select ?? options.select, lookups);
4330
4711
  const appendLookupStages$2 = (pipeline) => appendLookupStages(pipeline, lookups, projection, isKeyset && sort ? this._parseSort(sort) : void 0);
4331
4712
  if (isKeyset && sort) {
4332
4713
  const parsedSort = this._parseSort(sort);
4333
- const { validateKeysetSort } = await import("./PaginationEngine-Dvl8jcRY.mjs").then((n) => n.n);
4334
- const { encodeCursor, resolveCursorFilter } = await import("./PaginationEngine-Dvl8jcRY.mjs").then((n) => n.r);
4335
- const { getPrimaryField } = await import("./PaginationEngine-Dvl8jcRY.mjs").then((n) => n.n);
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);
4336
4717
  const normalizedSort = validateKeysetSort(parsedSort, this._pagination.config.strictKeysetSortFields);
4337
4718
  const cursorVersion = this._pagination.config.cursorVersion ?? 1;
4338
4719
  const minCursorVersion = this._pagination.config.minCursorVersion ?? 1;
4339
- 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 || {} };
4340
4722
  if (projection) {
4341
4723
  if (Object.values(projection).some((v) => v === 1)) {
4342
4724
  for (const sortField of Object.keys(normalizedSort)) if (!(sortField in projection)) projection[sortField] = 1;
@@ -4347,9 +4729,9 @@ var Repository = class extends RepositoryBase {
4347
4729
  pipeline.push({ $sort: normalizedSort });
4348
4730
  pipeline.push({ $limit: limit + 1 });
4349
4731
  appendLookupStages$2(pipeline);
4350
- const aggregation = this.Model.aggregate(pipeline).session(session || null);
4732
+ const aggregation = this.Model.aggregate(this._scopeJoins(pipeline, context)).session(session || null);
4351
4733
  if (collation) aggregation.collation(collation);
4352
- if (readPref) aggregation.read(readPref);
4734
+ applyToAggregate(aggregation, this._opts("aggregate", context));
4353
4735
  const data = await aggregation;
4354
4736
  const hasMore = data.length > limit;
4355
4737
  if (hasMore) data.pop();
@@ -4359,7 +4741,7 @@ var Repository = class extends RepositoryBase {
4359
4741
  data,
4360
4742
  limit,
4361
4743
  hasMore,
4362
- 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
4363
4745
  };
4364
4746
  await this._emitHook("after:lookupPopulate", {
4365
4747
  context,
@@ -4369,61 +4751,69 @@ var Repository = class extends RepositoryBase {
4369
4751
  }
4370
4752
  const page = pageFromContext ?? 1;
4371
4753
  const skip = (page - 1) * limit;
4372
- if (skip > 1e4) warn(`[mongokit] Large offset (${skip}) in lookupPopulate. Consider using keyset pagination: getAll({ sort, after, limit, lookups })`);
4373
- const dataPipeline = [];
4374
- if (filters && Object.keys(filters).length > 0) dataPipeline.push({ $match: filters });
4375
- if (sort) dataPipeline.push({ $sort: this._parseSort(sort) });
4376
- if (countStrategy === "none") {
4377
- dataPipeline.push({ $skip: skip }, { $limit: limit + 1 });
4378
- appendLookupStages$2(dataPipeline);
4379
- 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);
4380
4762
  if (collation) aggregation.collation(collation);
4381
- if (readPref) aggregation.read(readPref);
4382
- const data = await aggregation;
4383
- const hasNext = data.length > limit;
4384
- if (hasNext) data.pop();
4385
- const result = {
4386
- method: "offset",
4387
- data,
4388
- page,
4389
- 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 {
4390
4777
  total: 0,
4391
- pages: 0,
4392
- hasNext,
4393
- 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()
4394
4800
  };
4395
- await this._emitHook("after:lookupPopulate", {
4396
- context,
4397
- result
4398
- });
4399
- return result;
4400
- }
4401
- dataPipeline.push({ $skip: skip }, { $limit: limit });
4402
- appendLookupStages$2(dataPipeline);
4403
- const countPipeline = [];
4404
- if (filters && Object.keys(filters).length > 0) countPipeline.push({ $match: filters });
4405
- countPipeline.push({ $count: "total" });
4406
- const pipeline = [{ $facet: {
4407
- metadata: countPipeline,
4408
- data: dataPipeline
4409
- } }];
4410
- const aggregation = this.Model.aggregate(pipeline).session(session || null);
4411
- if (collation) aggregation.collation(collation);
4412
- if (readPref) aggregation.read(readPref);
4413
- const facetResult = (await aggregation)[0] || {
4414
- metadata: [],
4415
- data: []
4416
4801
  };
4417
- 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();
4418
4806
  const result = {
4419
4807
  method: "offset",
4420
- data: facetResult.data || [],
4808
+ data,
4421
4809
  page,
4422
4810
  limit,
4423
- total,
4424
- pages: calculateTotalPages(total, limit),
4425
- hasNext: page * limit < total,
4426
- 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
4427
4817
  };
4428
4818
  await this._emitHook("after:lookupPopulate", {
4429
4819
  context,
@@ -4839,8 +5229,14 @@ var Repository = class extends RepositoryBase {
4839
5229
  if (Array.isArray(labels) && (labels.includes("TransientTransactionError") || labels.includes("UnknownTransactionCommitResult")) || typeof hasLabel === "function" && (hasLabel.call(error, "TransientTransactionError") || hasLabel.call(error, "UnknownTransactionCommitResult"))) return error;
4840
5230
  if (error instanceof mongoose.mongo.MongoOperationTimeoutError) return error;
4841
5231
  if (error instanceof mongoose.Error.ValidationError) {
4842
- const messages = Object.values(error.errors).map((err) => err.message);
4843
- 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;
4844
5240
  }
4845
5241
  if (error instanceof mongoose.Error.CastError) return createError(400, `Invalid ${error.path}: ${error.value}`);
4846
5242
  const duplicateErr = parseDuplicateKeyError(error);