@classytic/mongokit 3.36.0 → 3.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/{PaginationEngine-CBS1XGzp.d.mts → PaginationEngine-D-P19Y9m.d.mts} +34 -9
  2. package/dist/PaginationEngine-DQ7beTCR.mjs +1182 -0
  3. package/dist/{Repository-BArvU32x.mjs → Repository-D7fDpyFx.mjs} +405 -37
  4. package/dist/{Repository-BdSkQ8vc.d.mts → Repository-IxI983_m.d.mts} +55 -8
  5. package/dist/actions/index.d.mts +1 -1
  6. package/dist/actions/index.mjs +1 -1
  7. package/dist/adapter/index.d.mts +21 -0
  8. package/dist/adapter/index.mjs +1 -1
  9. package/dist/{adapter-Bfqz8yHE.mjs → adapter-BrVfPFSW.mjs} +45 -1
  10. package/dist/ai/index.d.mts +1 -1
  11. package/dist/better-auth/index.d.mts +78 -1
  12. package/dist/better-auth/index.mjs +233 -3
  13. package/dist/{create-repository-BU4x9Kb0.d.mts → create-repository-CRXjDa3N.d.mts} +5 -5
  14. package/dist/{create-repository-CPNbktFG.mjs → create-repository-DOe9E_5F.mjs} +3 -3
  15. package/dist/{index-DKEql4cL.d.mts → index-CmjNyulu.d.mts} +67 -8
  16. package/dist/index.d.mts +77 -13
  17. package/dist/index.mjs +31 -8
  18. package/dist/indexes/verify-indexes.d.mts +69 -0
  19. package/dist/indexes/verify-indexes.mjs +76 -0
  20. package/dist/{method-registry.plugin-DblM7V6j.mjs → method-registry.plugin-C63pGn1d.mjs} +54 -4
  21. package/dist/{multi-tenant.plugin-BwSvTirB.d.mts → multi-tenant.plugin-B-tzNeYL.d.mts} +60 -4
  22. package/dist/{operations-DZodvjC-.d.mts → operations-CrRIoc8Z.d.mts} +22 -7
  23. package/dist/outbox/index.d.mts +1 -1
  24. package/dist/outbox/index.mjs +2 -2
  25. package/dist/pagination/PaginationEngine.d.mts +1 -1
  26. package/dist/pagination/PaginationEngine.mjs +1 -1
  27. package/dist/pagination/utils/limits.d.mts +47 -0
  28. package/dist/pagination/utils/limits.mjs +85 -0
  29. package/dist/pagination-BpIv2bdS.d.mts +364 -0
  30. package/dist/{plugin-options-DXnlKM3B.d.mts → plugin-options-BuvtOSZf.d.mts} +2 -2
  31. package/dist/plugins/index.d.mts +3 -3
  32. package/dist/plugins/index.mjs +3 -3
  33. package/dist/{repository-H_ffGgpE.d.mts → repository-ZbLtL7Nk.d.mts} +30 -4
  34. package/dist/tenant/index.d.mts +89 -0
  35. package/dist/tenant/index.mjs +94 -0
  36. package/dist/testkit/index.d.mts +2 -2
  37. package/dist/testkit/index.mjs +1 -1
  38. package/dist/{timestamp.plugin-BEc6TANm.mjs → timestamp.plugin-B5eiaItQ.mjs} +75 -8
  39. package/dist/{to-plain-Dyz_qvuo.d.mts → to-plain-B8ufk3Qz.d.mts} +2 -2
  40. package/dist/{update-jSXp3nu3.mjs → update-c_01A0if.mjs} +126 -73
  41. package/dist/utils/index.d.mts +1 -1
  42. package/dist/{validation-chain.plugin-CyK1s9Ze.mjs → validation-chain.plugin-Bju1D1Sn.mjs} +2 -2
  43. package/dist/{validation-chain.plugin-yQRob3Av.d.mts → validation-chain.plugin-DvrLJIbJ.d.mts} +3 -19
  44. package/package.json +15 -3
  45. package/dist/PaginationEngine-GdyX29tE.mjs +0 -797
  46. package/dist/core-62oo3N1C.d.mts +0 -50
  47. package/dist/pagination-jSUdrACK.d.mts +0 -158
@@ -1,11 +1,12 @@
1
1
  import { i as isValidIdForType, t as getSchemaIdType } from "./id-resolution-BqDrk0S2.mjs";
2
2
  import { a as narrowToIds, i as keysetFilter, o as selectKeysetChunk } from "./id-chunks-BmAwsqNk.mjs";
3
- import { a as count, b as LookupBuilder, c as getByQuery, d as deleteById, f as deleteByQuery, h as createMany, l as getOrCreate, m as create, n as update, o as exists, r as updateByQuery, s as getById, t as findOneAndUpdate, x as compileFilterToMongo, y as distinct } from "./update-jSXp3nu3.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";
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 { r as calculateTotalPages, t as PaginationEngine } from "./PaginationEngine-GdyX29tE.mjs";
6
+ import { c as resolveCursorSecrets, l as verifySignature, s as attachSignature, t as PaginationEngine } from "./PaginationEngine-DQ7beTCR.mjs";
7
+ import { calculateTotalPages } from "./pagination/utils/limits.mjs";
7
8
  import { hasNearOperator, rewriteNearForCount } from "./query/primitives/geo.mjs";
8
- import { RepositoryBase, runChunkedArchive, runChunkedPurge, throwIfAborted, validatePluginOrder, withRetry } from "@classytic/repo-core/repository";
9
+ import { RepositoryBase, createDistributionGuard, runChunkedArchive, runChunkedPurge, throwIfAborted, validatePluginOrder, withRetry } from "@classytic/repo-core/repository";
9
10
  import mongoose from "mongoose";
10
11
  import { isFilter } from "@classytic/repo-core/filter";
11
12
  import { VersionConflictError, conservativeMongoIsTransientConflict } from "@classytic/repo-core/errors";
@@ -805,8 +806,35 @@ function formatForUnit(unit) {
805
806
  */
806
807
  function compileFilterToMongoExpr(input) {
807
808
  if (input === void 0 || input === null) return true;
808
- if (!isFilter(input)) return input;
809
- return compile(input);
809
+ if (isFilter(input)) return compile(input);
810
+ if (typeof input === "boolean") return input;
811
+ /**
812
+ * Anything else must be an aggregation EXPRESSION, and an aggregation
813
+ * expression's operator keys are `$`-prefixed (`$eq`, `$and`, `$gt`).
814
+ *
815
+ * This used to `return input` for everything that was not Filter IR,
816
+ * described as "already an expression — pass through unchanged". A plain
817
+ * query object is not an expression: `{ status: { eq: 'paid' } }` reaches
818
+ * `$cond` as a non-empty object, which MongoDB evaluates as TRUTHY. So a
819
+ * measure's `where` written in query syntax silently matched every row and
820
+ * the filtered aggregate came back equal to the unfiltered one, with nothing
821
+ * raised.
822
+ *
823
+ * That syntax is not imaginary — it is exactly what `AggRequest.filter`
824
+ * accepts, because `compileFilterToMongo` runs `expandShorthands` over it.
825
+ * Two filter surfaces on one request object, one of which honoured the
826
+ * syntax and the other quietly ignored it.
827
+ *
828
+ * Refused rather than translated: a partial query-to-expression translator
829
+ * would get the common operators right and fail the same silent way on the
830
+ * rest, which is the failure being fixed.
831
+ */
832
+ if (typeof input === "object") {
833
+ const keys = Object.keys(input);
834
+ 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.`);
836
+ }
837
+ return input;
810
838
  }
811
839
  function compile(filter) {
812
840
  switch (filter.op) {
@@ -982,20 +1010,26 @@ function buildAggPipeline(req, schema) {
982
1010
  for (const alias of bucketAliases) addFields[bucketInternalKey(alias)] = compileDateBucket(req.dateBuckets[alias]);
983
1011
  stages.push({ $addFields: addFields });
984
1012
  }
985
- const groupStage = { _id: groupCols.length === 0 && bucketAliases.length === 0 ? null : (() => {
1013
+ const groupId = groupCols.length === 0 && bucketAliases.length === 0 ? null : (() => {
986
1014
  const id = {};
987
1015
  for (const field of groupCols) id[safeIdKey(field)] = `$${field}`;
988
1016
  for (const alias of bucketAliases) id[alias] = `$${bucketInternalKey(alias)}`;
989
1017
  return id;
990
- })() };
1018
+ })();
1019
+ const groupStage = { _id: groupId };
991
1020
  const distinctSetAliases = [];
992
1021
  const percentileAliases = [];
993
- for (const [alias, measure] of Object.entries(req.measures)) {
994
- groupStage[alias] = compileMeasure(measure).groupExpr;
995
- if (measure.op === "countDistinct") distinctSetAliases.push(alias);
996
- else if (measure.op === "percentile") percentileAliases.push(alias);
1022
+ const groupedDistinct = req.countDistinctStrategy === "grouped";
1023
+ const distinctAliases = Object.entries(req.measures).filter(([, m]) => m.op === "countDistinct").map(([alias]) => alias);
1024
+ if (groupedDistinct && distinctAliases.length > 0) appendGroupedDistinctStages(stages, req, groupId, distinctAliases);
1025
+ else {
1026
+ for (const [alias, measure] of Object.entries(req.measures)) {
1027
+ groupStage[alias] = compileMeasure(measure).groupExpr;
1028
+ if (measure.op === "countDistinct") distinctSetAliases.push(alias);
1029
+ else if (measure.op === "percentile") percentileAliases.push(alias);
1030
+ }
1031
+ stages.push({ $group: groupStage });
997
1032
  }
998
- stages.push({ $group: groupStage });
999
1033
  if (distinctSetAliases.length > 0) {
1000
1034
  const addFields = {};
1001
1035
  for (const alias of distinctSetAliases) addFields[alias] = { $size: { $ifNull: [`$${alias}`, []] } };
@@ -1064,12 +1098,108 @@ function validateTopN(topN, groupCols, bucketAliases, measures) {
1064
1098
  * - `'dense_rank'` → `$denseRank` (no gaps)
1065
1099
  * - `'row_number'` → `$documentNumber` (each row unique)
1066
1100
  */
1101
+ /**
1102
+ * Accumulators that survive being computed over a FINER partition and then
1103
+ * combined — the whole constraint on the grouped strategy.
1104
+ *
1105
+ * Pre-grouping by the distinct field splits every group into one sub-group per
1106
+ * distinct value, so each remaining measure must answer the same over the split
1107
+ * as it did whole. Summing is associative over a partition, and so are min and
1108
+ * max, so those four are exact.
1109
+ *
1110
+ * `avg` is not (the mean of means is not the mean); `percentile` and `stddev`
1111
+ * are not; and a SECOND `countDistinct` would need the partition to carry both
1112
+ * fields, which multiplies the rows and breaks the first count. Those are
1113
+ * REFUSED rather than approximated — a distinct count that is quietly wrong is
1114
+ * the exact failure this strategy exists to avoid.
1115
+ */
1116
+ const REAGGREGATABLE = {
1117
+ count: "$sum",
1118
+ sum: "$sum",
1119
+ min: "$min",
1120
+ max: "$max"
1121
+ };
1122
+ /** Internal `_id` slot holding one distinct value during the pre-group. */
1123
+ const distinctIdKey = (alias) => `__dv_${alias}`;
1124
+ /**
1125
+ * Compile `countDistinct` as pre-group + count instead of accumulate + size.
1126
+ *
1127
+ * `$addToSet` holds every distinct value of a group in that group's accumulator,
1128
+ * so memory grows with cardinality and MongoDB caps it at 100MB. Grouping by the
1129
+ * value instead turns each distinct value into a ROW, and rows spill. Nothing
1130
+ * accumulates, so cardinality stops being a memory question.
1131
+ *
1132
+ * The two semantics that had to be matched exactly, both measured against
1133
+ * `$addToSet` rather than assumed:
1134
+ *
1135
+ * - a MISSING field is not a distinct value, but an explicit `null` IS. A
1136
+ * naive `$group` disagrees on both, and on a group whose field is always
1137
+ * missing it answers 1 where `$addToSet` answers 0. The `$type` check is
1138
+ * what separates them: a missing field leaves the `_id` slot absent, an
1139
+ * explicit null leaves it `null`.
1140
+ * - `where` excludes a row from the COUNT without excluding it from the other
1141
+ * measures. `compileMeasure` already emits `$$REMOVE` for a non-matching
1142
+ * row, which lands it in the same "missing" slot — so the same `$type`
1143
+ * check covers the filtered case with no extra branch.
1144
+ */
1145
+ function appendGroupedDistinctStages(stages, req, groupId, distinctAliases) {
1146
+ if (distinctAliases.length > 1) throw createError(400, `mongokit/aggregate: countDistinctStrategy 'grouped' supports one countDistinct per request, got ${distinctAliases.length} (${distinctAliases.join(", ")}). Two distinct fields would have to share one partition, which multiplies the rows and makes both counts wrong. Issue them as separate requests, or use the default 'accumulator'.`);
1147
+ const distinctAlias = distinctAliases[0];
1148
+ const blocked = Object.entries(req.measures).filter(([alias, m]) => alias !== distinctAlias && !REAGGREGATABLE[m.op]);
1149
+ if (blocked.length > 0) throw createError(400, `mongokit/aggregate: countDistinctStrategy 'grouped' cannot combine with ${blocked.map(([a, m]) => `'${a}' (${m.op})`).join(", ")}. Pre-grouping by the distinct field splits each group, and only count/sum/min/max survive being recombined — an ${blocked[0][1].op} over the split would be silently wrong. Compute it in a separate request, or use the default 'accumulator' strategy.`);
1150
+ const dvKey = distinctIdKey(distinctAlias);
1151
+ const preId = { ...groupId ?? {} };
1152
+ preId[dvKey] = compileMeasure(req.measures[distinctAlias]).groupExpr;
1153
+ preId[dvKey] = preId[dvKey].$addToSet;
1154
+ const preGroup = { _id: preId };
1155
+ for (const [alias, measure] of Object.entries(req.measures)) {
1156
+ if (alias === distinctAlias) continue;
1157
+ preGroup[alias] = compileMeasure(measure).groupExpr;
1158
+ }
1159
+ stages.push({ $group: preGroup });
1160
+ const regroup = { _id: groupId === null ? null : Object.fromEntries(Object.keys(groupId).map((k) => [k, `$_id.${k}`])) };
1161
+ regroup[distinctAlias] = { $sum: { $cond: [
1162
+ { $eq: [{ $type: `$_id.${dvKey}` }, "missing"] },
1163
+ 0,
1164
+ 1
1165
+ ] } };
1166
+ for (const [alias, measure] of Object.entries(req.measures)) {
1167
+ if (alias === distinctAlias) continue;
1168
+ regroup[alias] = { [REAGGREGATABLE[measure.op]]: `$${alias}` };
1169
+ }
1170
+ stages.push({ $group: regroup });
1171
+ }
1067
1172
  function appendTopNStages(stages, topN) {
1068
1173
  const partitionList = Array.isArray(topN.partitionBy) ? topN.partitionBy : [topN.partitionBy];
1069
- const partitionExpr = partitionList.length === 1 ? `$${partitionList[0]}` : { $concat: partitionList.flatMap((field, i) => {
1070
- const ref = { $toString: { $ifNull: [`$${field}`, "__NULL__"] } };
1071
- return i === 0 ? [ref] : ["", ref];
1072
- }) };
1174
+ /**
1175
+ * A compound partition is a TUPLE, and a tuple's identity cannot be a string.
1176
+ *
1177
+ * This built the key by `$toString`-ing each value and joining with U+0001,
1178
+ * mapping null to the literal `'__NULL__'`. Both halves lose information, and
1179
+ * the loss merges partitions that are not the same — a value CONTAINING the
1180
+ * separator, or a document genuinely holding the string `'__NULL__'`, lands
1181
+ * on another partition's key. Two unrelated partitions then rank as one, so
1182
+ * `topN` returns rows that are not the top N of anything. Measured: four rows
1183
+ * across two partitions came back ranked 1, 2, 3, 4.
1184
+ *
1185
+ * A DOCUMENT expression compares by value, field by field, with no
1186
+ * serialization in between, so distinct tuples stay distinct whatever they
1187
+ * contain. An array would read more naturally and is not available: Mongo
1188
+ * refuses it outright with "An expression used to partition cannot evaluate
1189
+ * to value of type array".
1190
+ *
1191
+ * Keys are positional (`k0`, `k1`, ...) rather than the field names, because
1192
+ * a partition field may be a dotted path and a dot is not legal in an
1193
+ * expression document's key. They are internal to this stage and never
1194
+ * surface in output.
1195
+ *
1196
+ * `$ifNull` to null keeps arity fixed, so a missing field and an explicit
1197
+ * null partition together the way SQL's `PARTITION BY` groups NULLs, while a
1198
+ * real `'__NULL__'` string stays separate from both.
1199
+ *
1200
+ * Single-key partitions keep the bare path: same semantics, cleaner plan.
1201
+ */
1202
+ const partitionExpr = partitionList.length === 1 ? `$${partitionList[0]}` : Object.fromEntries(partitionList.map((field, i) => [`k${i}`, { $ifNull: [`$${field}`, null] }]));
1073
1203
  const tiesOp = topN.ties === "dense_rank" ? "$denseRank" : topN.ties === "row_number" ? "$documentNumber" : "$rank";
1074
1204
  const RANK_FIELD = "__topNRank";
1075
1205
  stages.push({ $setWindowFields: {
@@ -1307,8 +1437,22 @@ async function executeAgg(Model, req, options = {}) {
1307
1437
  * keyset predicate runs AFTER the post-group `$project` stage, which
1308
1438
  * is when the output column names exist.
1309
1439
  */
1310
- function decodeAggCursor$1(cursor) {
1311
- return decodeAggCursor(cursor, "mongokit");
1440
+ /**
1441
+ * The aggregate cursor is signed by the SAME key as the find-based one.
1442
+ *
1443
+ * Two cursor codecs are exposed on the wire, and a deployment that signed only
1444
+ * the one on `getAll` would still hand out a tamperable position on every
1445
+ * aggregate keyset endpoint. A guarantee with an unsigned second door is not a
1446
+ * guarantee — it is a guarantee-shaped thing that reviewers stop checking.
1447
+ *
1448
+ * The signature wraps repo-core's kit-neutral codec rather than living inside
1449
+ * it: repo-core's codec is browser-safe by contract and `node:crypto` is not.
1450
+ */
1451
+ function encodeAggCursor$1(row, sort, secret) {
1452
+ return attachSignature(encodeAggCursor(row, sort), resolveCursorSecrets(secret));
1453
+ }
1454
+ function decodeAggCursor$1(cursor, secret) {
1455
+ return decodeAggCursor(verifySignature(cursor, resolveCursorSecrets(secret)), "mongokit");
1312
1456
  }
1313
1457
  /**
1314
1458
  * Build a `$match` stage that selects rows AFTER the cursor row given
@@ -2231,6 +2375,41 @@ function assertCasOwnsVersionField(opName, patch, versionField) {
2231
2375
  throw createError(400, `[${opName}] payload's ${op} targets '${versionField}', but the optimistic-concurrency CAS owns that field — it increments it as part of the same write. ` + (op === "$inc" ? `Only \`$inc: { ${versionField}: 1 }\` is accepted (it matches what the CAS does); any other increment would be silently overridden. ` : `A conflicting ${op} would either fail the update with a Mongo path conflict or be silently overridden. `) + `Remove it; the expected version belongs in \`options.ifVersion\`.`);
2232
2376
  }
2233
2377
  }
2378
+ /** Filter-bearing ops — those a policy plugin can inject a scope into. */
2379
+ function carriesAFilter(op) {
2380
+ const policyKey = OP_REGISTRY[op].policyKey;
2381
+ return policyKey === "query" || policyKey === "filters";
2382
+ }
2383
+ /**
2384
+ * Shapes the guard can never say anything useful about, whatever the key:
2385
+ * `watch` (a change stream is cluster-wide however it is filtered) and
2386
+ * `getOrCreate` (its filter is the unique key by construction, and a unique
2387
+ * index on a sharded collection must already contain the shard key).
2388
+ */
2389
+ const DISTRIBUTION_EXEMPT_SHAPES = /* @__PURE__ */ new Set(["watch", "getOrCreate"]);
2390
+ /**
2391
+ * Verbs the distribution guard inspects — every op whose filter the CALLER
2392
+ * assembles and can therefore omit the shard key.
2393
+ *
2394
+ * DERIVED from `OP_REGISTRY`, not hardcoded: a future query-shaped op is
2395
+ * covered by its one registry entry, which is the same rule the strict-query
2396
+ * diagnostic below follows and the redundancy the registry exists to remove.
2397
+ */
2398
+ const DISTRIBUTION_GUARDED_OPERATIONS = new Set(ALL_OPERATIONS.filter((op) => carriesAFilter(op) && !OP_REGISTRY[op].hasIdContext && !DISTRIBUTION_EXEMPT_SHAPES.has(op)));
2399
+ /**
2400
+ * Id-addressed verbs (`getById`, `update`, `delete`, `claim*`, `restore`) —
2401
+ * guarded ONLY when the distribution key is not the id field.
2402
+ *
2403
+ * The reasoning they were first excluded under — "the `_id` match is routed,
2404
+ * not scattered" — holds only when `_id` IS the shard key. Sharded on anything
2405
+ * else (`organizationId`, the tenant-prefixed design this guard mostly exists
2406
+ * for), a by-id update is a broadcast to every shard; MongoDB before 7.0
2407
+ * rejected `updateOne`/`deleteOne` without the shard key outright. These verbs
2408
+ * all take a filter slot too, which is where tenant scope lands — so a scoped
2409
+ * write satisfies the guard and only a `bypassTenant` one is flagged, exactly
2410
+ * the call that deserves the scrutiny.
2411
+ */
2412
+ const ID_ADDRESSED_OPERATIONS = new Set(ALL_OPERATIONS.filter((op) => carriesAFilter(op) && OP_REGISTRY[op].hasIdContext && !DISTRIBUTION_EXEMPT_SHAPES.has(op)));
2234
2413
  /** Mongo `operationType` → portable `ChangeEvent.operation`. */
2235
2414
  const CHANGE_OPERATION_MAP = {
2236
2415
  insert: "create",
@@ -2268,6 +2447,24 @@ function changeEventTimestamp(change) {
2268
2447
  * OBSERVABILITY=300, DEFAULT=500) and the two refs are reference-equal.
2269
2448
  */
2270
2449
  const HOOK_PRIORITY$1 = HOOK_PRIORITY;
2450
+ /**
2451
+ * `capped` needs an envelope that can say "this total is a FLOOR".
2452
+ *
2453
+ * `paginate()` and `aggregatePaginate()` carry `totalIsLowerBound` through
2454
+ * mongokit's `TExtra` slot. `lookupPopulate` and the aggregate-IR offset path
2455
+ * return repo-core's CORE envelope, which has no such slot — so a bounded count
2456
+ * there would be indistinguishable from a real one, and a caller would render a
2457
+ * ceiling as a total. That is the failure the strategy exists to prevent.
2458
+ *
2459
+ * So they count exactly, and SAY they did. A deployment-wide `capped` policy
2460
+ * keeps working (an exact count is slower, never wrong); the log line is what
2461
+ * makes the one path that ignored the policy findable instead of mysterious.
2462
+ */
2463
+ function downgradeCappedCount(strategy, where) {
2464
+ 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.`);
2466
+ return "exact";
2467
+ }
2271
2468
  var Repository = class extends RepositoryBase {
2272
2469
  /**
2273
2470
  * Runtime capability descriptor required by `StandardRepo<TDoc>`
@@ -2319,6 +2516,18 @@ var Repository = class extends RepositoryBase {
2319
2516
  * the `Middleware<TDoc>` type for the protocol.
2320
2517
  */
2321
2518
  _middlewares = [];
2519
+ /**
2520
+ * Shard-key guard built once from `options.distribution`; `undefined`
2521
+ * when not configured so the per-op check is a single falsy test.
2522
+ */
2523
+ _checkDistribution;
2524
+ /**
2525
+ * Whether id-addressed verbs are guarded too — true unless the
2526
+ * distribution key IS the id field, where an `_id` match is genuinely
2527
+ * routed and guarding would flag every by-id call. See
2528
+ * {@link ID_ADDRESSED_OPERATIONS}.
2529
+ */
2530
+ _guardIdAddressed = false;
2322
2531
  constructor(Model, plugins = [], paginationConfig = {}, options = {}) {
2323
2532
  super({
2324
2533
  name: Model.modelName,
@@ -2340,6 +2549,10 @@ var Repository = class extends RepositoryBase {
2340
2549
  this.searchMode = options.searchMode ?? "text";
2341
2550
  this.searchFields = options.searchFields;
2342
2551
  this._wireStrictQueryStripDiagnostic(options.warnOnStrictQueryStrip === true);
2552
+ this._checkDistribution = options.distribution ? createDistributionGuard(options.distribution, options.distribution.onMiss ?? ((info) => {
2553
+ warn(`[mongokit] Repository "${this.model}": ${info.operation} filter omits the distribution key "${info.key}" — this fans out across every shard. Include the key, list the op in distribution.exemptOperations, or set onMissingKey: 'off'.`);
2554
+ })) : void 0;
2555
+ this._guardIdAddressed = options.distribution !== void 0 && options.distribution.key !== this.idField;
2343
2556
  if (this.searchMode === "regex" && (!this.searchFields || this.searchFields.length === 0)) warn(`[mongokit] Repository "${this.model}" configured with searchMode: 'regex' but no searchFields provided. getAll({ search }) will throw until searchFields is set.`);
2344
2557
  for (const plugin of plugins) this.use(plugin);
2345
2558
  if (options.requirePlugins && options.requirePlugins.length > 0) {
@@ -2635,6 +2848,10 @@ var Repository = class extends RepositoryBase {
2635
2848
  const idType = getSchemaIdType(this.Model.schema);
2636
2849
  if (!isValidIdForType(id, idType)) {
2637
2850
  if (wantsThrow) throw createError(404, "Document not found");
2851
+ await this._emitHook("after:getById", {
2852
+ context,
2853
+ result: null
2854
+ });
2638
2855
  return null;
2639
2856
  }
2640
2857
  const result = await this._withResilience(context, () => getById(this.Model, id, context));
@@ -2867,8 +3084,12 @@ var Repository = class extends RepositoryBase {
2867
3084
  * repo without a filter to observe deletes.
2868
3085
  * - `options.signal` ends the iterator (closes the stream). A
2869
3086
  * pre-aborted signal rejects at the op boundary like every other op.
2870
- * - `options.resumeAfter` forwards a previously captured Mongo
2871
- * resume token for at-least-once consumption across restarts.
3087
+ * - Every yielded event carries `resumeToken` (the change document's
3088
+ * `_id`). Persist the last one you finished processing and pass it
3089
+ * back as `options.resumeAfter` — or `options.startAfter` to also
3090
+ * step past an invalidate event — for at-least-once consumption
3091
+ * across restarts. Setting both is rejected up front (Mongo forbids
3092
+ * the combination; the driver error is far less legible).
2872
3093
  *
2873
3094
  * Context keys for policy plugins (`organizationId`, `bypassTenant`,
2874
3095
  * `includeDeleted`, ...) ride the options bag at runtime — same as every
@@ -2885,6 +3106,7 @@ var Repository = class extends RepositoryBase {
2885
3106
  * ```
2886
3107
  */
2887
3108
  async *watch(filter, options = {}) {
3109
+ if (options.resumeAfter !== void 0 && options.startAfter !== void 0) throw createError(400, "watch() accepts either `resumeAfter` or `startAfter`, not both — they are alternative resume positions for the same stream.");
2888
3110
  const context = await this._buildContext("watch", {
2889
3111
  query: filter ?? {},
2890
3112
  ...options
@@ -2899,9 +3121,60 @@ var Repository = class extends RepositoryBase {
2899
3121
  if (Object.keys(compiled).length > 0) pipeline.push({ $match: prefixMatchPaths(compiled, "fullDocument") });
2900
3122
  const stream = this.Model.watch(pipeline, {
2901
3123
  fullDocument: "updateLookup",
2902
- ...options.resumeAfter !== void 0 ? { resumeAfter: options.resumeAfter } : {}
3124
+ ...options.resumeAfter !== void 0 ? { resumeAfter: options.resumeAfter } : {},
3125
+ ...options.startAfter !== void 0 ? { startAfter: options.startAfter } : {}
2903
3126
  });
3127
+ /**
3128
+ * A change feed has NO backpressure — the server pushes, and this bridge
3129
+ * buffers whatever the consumer has not reached yet. Unbounded, a consumer
3130
+ * merely slower than the write rate becomes unbounded memory growth whose
3131
+ * symptom (an OOM) appears nowhere near its cause.
3132
+ *
3133
+ * `head` rather than `queue.shift()`: shift is O(n) on a real array, so a
3134
+ * backlog made draining quadratic exactly when the process was already
3135
+ * struggling. The array is compacted once the read head passes halfway, so
3136
+ * the retained slots stay bounded by the live depth rather than by
3137
+ * everything ever enqueued.
3138
+ */
3139
+ /**
3140
+ * An unusable bound must not read as "no bound".
3141
+ *
3142
+ * `maxBuffered > 0` is the guard below, and a negative, a fraction,
3143
+ * `Infinity` and `NaN` all slip past it into UNBOUNDED buffering — the
3144
+ * failure this option exists to prevent, silently, in a deployment that
3145
+ * believed it had configured a ceiling. Same shape as the `countLimit`
3146
+ * ceiling, and refused the same way.
3147
+ *
3148
+ * `0` stays legal because it is the documented, explicit escape hatch;
3149
+ * every other non-positive-integer is a mistake.
3150
+ */
3151
+ const maxBuffered = options.maxBufferedEvents ?? 1024;
3152
+ if (maxBuffered !== 0 && !(Number.isInteger(maxBuffered) && maxBuffered > 0)) throw createError(400, `watch(): maxBufferedEvents must be a positive integer, or 0 to disable the bound — got ${String(maxBuffered)}. A negative, fractional, infinite or NaN value would silently buffer without limit, which is the failure the option exists to prevent.`);
2904
3153
  const queue = [];
3154
+ let head = 0;
3155
+ let overflowed = false;
3156
+ /**
3157
+ * Delivered is not processed.
3158
+ *
3159
+ * `lastDelivered` is set before the `yield`; `lastAcknowledged` only when
3160
+ * execution RESUMES after it, which happens when the consumer comes back
3161
+ * for the next value — the one observable proof it finished with the
3162
+ * previous one.
3163
+ *
3164
+ * The distinction is the whole difference between at-least-once and
3165
+ * silent loss. Resuming after a merely DELIVERED token skips an event the
3166
+ * consumer may have been halfway through when the stream ended: it was
3167
+ * handed over, never processed, and never seen again. Resuming after the
3168
+ * ACKNOWLEDGED one redelivers at most the in-flight event, which an
3169
+ * idempotent consumer is already built to absorb.
3170
+ *
3171
+ * The asymmetry is the point: redelivering costs a duplicate, skipping
3172
+ * costs a lost write. For money, inventory and outbox consumers those are
3173
+ * not comparable.
3174
+ */
3175
+ let lastDeliveredToken;
3176
+ let lastAcknowledgedToken;
3177
+ const depth = () => queue.length - head;
2905
3178
  let streamError = null;
2906
3179
  let ended = false;
2907
3180
  let notify = null;
@@ -2911,6 +3184,26 @@ var Repository = class extends RepositoryBase {
2911
3184
  resolve?.();
2912
3185
  };
2913
3186
  const onChange = (change) => {
3187
+ if (overflowed) return;
3188
+ if (maxBuffered > 0 && depth() >= maxBuffered) {
3189
+ overflowed = true;
3190
+ streamError = createError(503, `watch(): consumer fell more than ${maxBuffered} events behind and the buffer is full. Events are never dropped, so the stream ends here — resume with \`startAfter\` from this error's \`resumeToken\`, which is the last event the consumer ACKNOWLEDGED (not the last delivered: resuming past an event still being processed would lose it). Raise \`maxBufferedEvents\` for burst tolerance, or move consumption off the iterator onto a durable queue.`);
3191
+ /**
3192
+ * `resumeToken` is the ACKNOWLEDGED token, never the delivered one:
3193
+ * restarting after an event the consumer had not finished would drop
3194
+ * it. `deliveredToken` rides along for diagnostics only — a supervisor
3195
+ * that resumes from it reintroduces the loss this separation prevents.
3196
+ *
3197
+ * Both may be `undefined` when overflow arrives before the consumer
3198
+ * completed anything; the caller then restarts from whatever position
3199
+ * it opened the stream with, not from here.
3200
+ */
3201
+ const overflowError = streamError;
3202
+ overflowError.resumeToken = lastAcknowledgedToken;
3203
+ overflowError.deliveredToken = lastDeliveredToken;
3204
+ wake();
3205
+ return;
3206
+ }
2914
3207
  queue.push(change);
2915
3208
  wake();
2916
3209
  };
@@ -2943,16 +3236,23 @@ var Repository = class extends RepositoryBase {
2943
3236
  }
2944
3237
  while (true) {
2945
3238
  if (signal?.aborted) return;
2946
- const change = queue.shift();
3239
+ const change = head < queue.length ? queue[head++] : void 0;
3240
+ if (head > 32 && head * 2 >= queue.length) {
3241
+ queue.splice(0, head);
3242
+ head = 0;
3243
+ }
2947
3244
  if (change) {
2948
3245
  const operation = CHANGE_OPERATION_MAP[change.operationType];
2949
3246
  if (!operation) continue;
3247
+ lastDeliveredToken = change._id;
2950
3248
  yield {
2951
3249
  operation,
2952
3250
  id: change.documentKey?._id,
2953
3251
  ...change.fullDocument !== void 0 ? { doc: change.fullDocument } : {},
2954
- timestamp: changeEventTimestamp(change)
3252
+ timestamp: changeEventTimestamp(change),
3253
+ ...change._id !== void 0 ? { resumeToken: change._id } : {}
2955
3254
  };
3255
+ lastAcknowledgedToken = change._id;
2956
3256
  continue;
2957
3257
  }
2958
3258
  if (streamError) {
@@ -3029,8 +3329,9 @@ var Repository = class extends RepositoryBase {
3029
3329
  const limit = context.limit ?? params.limit ?? params.pagination?.limit ?? this._pagination.config.defaultLimit;
3030
3330
  const page = context.page ?? params.pagination?.page ?? params.page;
3031
3331
  const after = context.after ?? params.cursor ?? params.after;
3332
+ const before = context.before ?? params.before;
3032
3333
  const mode = context.mode ?? params.mode;
3033
- const useKeyset = this._detectPaginationMode(mode, page, after, sort, context, params);
3334
+ const useKeyset = this._detectPaginationMode(mode, page, after ?? before, sort, context, params);
3034
3335
  const query = this._buildSearchQuery(filters, search);
3035
3336
  const populateSpec = options.populateOptions || params.populateOptions || context.populate || options.populate;
3036
3337
  const paginationOptions = {
@@ -3043,6 +3344,7 @@ var Repository = class extends RepositoryBase {
3043
3344
  session: options.session,
3044
3345
  hint: context.hint ?? params.hint,
3045
3346
  maxTimeMS: context.maxTimeMS ?? params.maxTimeMS,
3347
+ countLimit: context.countLimit ?? params.countLimit,
3046
3348
  readPreference: context.readPreference ?? options.readPreference ?? params.readPreference,
3047
3349
  collation: context.collation ?? params.collation
3048
3350
  };
@@ -3059,7 +3361,8 @@ var Repository = class extends RepositoryBase {
3059
3361
  session: options.session,
3060
3362
  readPreference: paginationOptions.readPreference,
3061
3363
  collation: paginationOptions.collation,
3062
- countStrategy: context.countStrategy ?? params.countStrategy
3364
+ countStrategy: context.countStrategy ?? params.countStrategy,
3365
+ countLimit: context.countLimit ?? params.countLimit
3063
3366
  });
3064
3367
  });
3065
3368
  return this._runOp("getAll", context, async () => {
@@ -3067,7 +3370,8 @@ var Repository = class extends RepositoryBase {
3067
3370
  if (useKeyset) result = await this._pagination.stream({
3068
3371
  ...paginationOptions,
3069
3372
  sort: paginationOptions.sort,
3070
- after
3373
+ after,
3374
+ before
3071
3375
  });
3072
3376
  else {
3073
3377
  let countFilters;
@@ -4255,7 +4559,7 @@ var Repository = class extends RepositoryBase {
4255
4559
  async _executeAggregatePaginate(finalReq, useKeyset, limit, session) {
4256
4560
  if (useKeyset) {
4257
4561
  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");
4258
- const cursor = finalReq.after ? decodeAggCursor$1(finalReq.after) : void 0;
4562
+ const cursor = finalReq.after ? decodeAggCursor$1(finalReq.after, this._pagination.config.cursorSecret) : void 0;
4259
4563
  const keysetMatch = cursor ? buildKeysetPredicate(finalReq.sort, cursor) : void 0;
4260
4564
  const { pipeline, prePaginationIndex } = buildAggPipeline(finalReq, this.Model.schema);
4261
4565
  const headStages = pipeline.slice(0, prePaginationIndex);
@@ -4274,11 +4578,11 @@ var Repository = class extends RepositoryBase {
4274
4578
  data,
4275
4579
  limit,
4276
4580
  hasMore,
4277
- next: hasMore && data.length > 0 ? encodeAggCursor(data[data.length - 1], finalReq.sort) : null
4581
+ next: hasMore && data.length > 0 ? encodeAggCursor$1(data[data.length - 1], finalReq.sort, this._pagination.config.cursorSecret) : null
4278
4582
  };
4279
4583
  }
4280
4584
  const page = Math.max(1, finalReq.page ?? 1);
4281
- const countStrategy = finalReq.countStrategy ?? "exact";
4585
+ const countStrategy = downgradeCappedCount(finalReq.countStrategy ?? this._pagination.config.defaultCountStrategy, "aggregatePaginateIr");
4282
4586
  const offset = (page - 1) * limit;
4283
4587
  if (countStrategy === "none") {
4284
4588
  const peek = await executeAgg(this.Model, {
@@ -4407,18 +4711,18 @@ var Repository = class extends RepositoryBase {
4407
4711
  const after = context.after ?? options.after;
4408
4712
  const pageFromContext = context.page ?? options.page;
4409
4713
  const isKeyset = !!after || !pageFromContext && !!sort;
4410
- const countStrategy = context.countStrategy ?? options.countStrategy ?? "exact";
4714
+ const countStrategy = downgradeCappedCount(context.countStrategy ?? options.countStrategy ?? this._pagination.config.defaultCountStrategy, "lookupPopulate");
4411
4715
  const projection = buildLookupProjection(context.select ?? options.select, lookups);
4412
4716
  const appendLookupStages$2 = (pipeline) => appendLookupStages(pipeline, lookups, projection, isKeyset && sort ? this._parseSort(sort) : void 0);
4413
4717
  if (isKeyset && sort) {
4414
4718
  const parsedSort = this._parseSort(sort);
4415
- const { validateKeysetSort } = await import("./PaginationEngine-GdyX29tE.mjs").then((n) => n.n);
4416
- const { encodeCursor, resolveCursorFilter } = await import("./PaginationEngine-GdyX29tE.mjs").then((n) => n.i);
4417
- const { getPrimaryField } = await import("./PaginationEngine-GdyX29tE.mjs").then((n) => n.n);
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);
4418
4722
  const normalizedSort = validateKeysetSort(parsedSort, this._pagination.config.strictKeysetSortFields);
4419
4723
  const cursorVersion = this._pagination.config.cursorVersion ?? 1;
4420
4724
  const minCursorVersion = this._pagination.config.minCursorVersion ?? 1;
4421
- const matchFilters = after ? resolveCursorFilter(after, normalizedSort, cursorVersion, { ...filters || {} }, minCursorVersion) : { ...filters || {} };
4725
+ const matchFilters = after ? resolveCursorFilter(after, normalizedSort, cursorVersion, { ...filters || {} }, minCursorVersion, normalizedSort, this._pagination.config.cursorSecret) : { ...filters || {} };
4422
4726
  if (projection) {
4423
4727
  if (Object.values(projection).some((v) => v === 1)) {
4424
4728
  for (const sortField of Object.keys(normalizedSort)) if (!(sortField in projection)) projection[sortField] = 1;
@@ -4441,7 +4745,7 @@ var Repository = class extends RepositoryBase {
4441
4745
  data,
4442
4746
  limit,
4443
4747
  hasMore,
4444
- next: hasMore && data.length > 0 ? encodeCursor(data[data.length - 1], primaryField, normalizedSort, cursorVersion) : null
4748
+ next: hasMore && data.length > 0 ? encodeCursor(data[data.length - 1], primaryField, normalizedSort, cursorVersion, this._pagination.config.cursorSecret) : null
4445
4749
  };
4446
4750
  await this._emitHook("after:lookupPopulate", {
4447
4751
  context,
@@ -4641,7 +4945,42 @@ var Repository = class extends RepositoryBase {
4641
4945
  async _buildContext(operation, options) {
4642
4946
  throwIfAborted(options.signal);
4643
4947
  const normalized = this._normalizeFilterSlots(options);
4644
- return await super._buildContext(operation, normalized);
4948
+ const base = await super._buildContext(operation, normalized);
4949
+ this._guardDistribution(operation, base);
4950
+ return base;
4951
+ }
4952
+ /**
4953
+ * Feed the resolved filter of a guarded verb to the distribution guard.
4954
+ * The slot follows `OP_REGISTRY[op].policyKey` (`query` vs `filters`);
4955
+ * the portable aggregate verbs ALSO carry the caller's filter on
4956
+ * `aggRequest.filter` (merged into the pipeline later by
4957
+ * `_injectPolicyScopeIntoAgg`), and the kit-native pipeline verbs may
4958
+ * carry it as leading `$match` stages — both are folded in so a key the
4959
+ * caller did supply is never reported missing.
4960
+ */
4961
+ _guardDistribution(operation, context) {
4962
+ if (!this._checkDistribution) return;
4963
+ if (!(DISTRIBUTION_GUARDED_OPERATIONS.has(operation) || this._guardIdAddressed && ID_ADDRESSED_OPERATIONS.has(operation))) return;
4964
+ const policyKey = OP_REGISTRY[operation]?.policyKey;
4965
+ const parts = [];
4966
+ const push = (candidate) => {
4967
+ if (!candidate || typeof candidate !== "object") return;
4968
+ const compiled = compileFilterToMongo(candidate);
4969
+ if (Object.keys(compiled).length > 0) parts.push(compiled);
4970
+ };
4971
+ push(policyKey === "filters" ? context.filters : context.query);
4972
+ push(context.aggRequest?.filter);
4973
+ const pipeline = context.pipeline;
4974
+ if (Array.isArray(pipeline)) for (const stage of pipeline) {
4975
+ if (!stage || typeof stage !== "object") break;
4976
+ if ("$geoNear" in stage) {
4977
+ push(stage.$geoNear?.query);
4978
+ continue;
4979
+ }
4980
+ if (!("$match" in stage)) break;
4981
+ push(stage.$match);
4982
+ }
4983
+ this._checkDistribution(operation, parts.length === 0 ? void 0 : parts.length === 1 ? parts[0] : { $and: parts });
4645
4984
  }
4646
4985
  /**
4647
4986
  * Walk the well-known filter-carrying keys (`query`, `filters`,
@@ -4678,10 +5017,17 @@ var Repository = class extends RepositoryBase {
4678
5017
  }
4679
5018
  /**
4680
5019
  * Detect whether to use keyset (cursor) or offset (page) pagination.
5020
+ * Precedence: explicit `mode` → `page` (offset) → `after` (keyset) →
5021
+ * `PaginationConfig.defaultMode` → the sort heuristic (a non-default
5022
+ * sort with no page means the caller wants a cursor).
4681
5023
  */
4682
5024
  _detectPaginationMode(mode, page, after, sort, context, params) {
4683
5025
  if (mode) return mode === "keyset";
4684
- return !page && !!(after || sort !== "-createdAt" && (context.sort ?? params.sort));
5026
+ if (page) return false;
5027
+ if (after) return true;
5028
+ const defaultMode = this._pagination.config.defaultMode;
5029
+ if (defaultMode) return defaultMode === "keyset";
5030
+ return sort !== "-createdAt" && !!(context.sort ?? params.sort);
4685
5031
  }
4686
5032
  /**
4687
5033
  * Build the MongoDB query filter with search merged in. Handles text
@@ -4811,7 +5157,29 @@ var Repository = class extends RepositoryBase {
4811
5157
  * `errorLabels` exactly as we received it. The caller still sees a
4812
5158
  * thrown error — only the wrapping layer is skipped.
4813
5159
  */
5160
+ /**
5161
+ * Translate a driver error into the repository's error contract, WITHOUT
5162
+ * losing structured diagnostics an action attached to it.
5163
+ *
5164
+ * The translation replaces the object — an E11000 becomes a 409 — so any
5165
+ * property a caller was meant to read went with it. `createMany` attaches a
5166
+ * `partial` report naming which documents landed, and it arrived nowhere:
5167
+ * the report existed, the test for it failed, and the only evidence was the
5168
+ * absence.
5169
+ *
5170
+ * Carried explicitly rather than by copying every own-property, because the
5171
+ * wrap is deliberate: the point is to hide driver internals, and a blanket
5172
+ * copy would put them back.
5173
+ */
4814
5174
  _handleError(error) {
5175
+ const translated = this._translateError(error);
5176
+ if (translated !== error) {
5177
+ const partial = error.partial;
5178
+ if (partial !== void 0) translated.partial = partial;
5179
+ }
5180
+ return translated;
5181
+ }
5182
+ _translateError(error) {
4815
5183
  const labels = error.errorLabels;
4816
5184
  const hasLabel = error.hasErrorLabel;
4817
5185
  if (Array.isArray(labels) && (labels.includes("TransientTransactionError") || labels.includes("UnknownTransactionCommitResult")) || typeof hasLabel === "function" && (hasLabel.call(error, "TransientTransactionError") || hasLabel.call(error, "UnknownTransactionCommitResult"))) return error;